From 19d527567fb03975bebfc102746fba73a70d5a9c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 25 Mar 2026 21:10:10 +0000 Subject: [PATCH 01/14] Dataset style rules basics added --- .../datasets/src/adapters/maplibre/index.js | 2 +- .../src/adapters/maplibre/layerBuilders.js | 113 +++++++++ .../src/adapters/maplibre/layerIds.js | 66 +++++ .../{adapter.js => maplibreLayerAdapter.js} | 226 +++++++----------- .../src/adapters/maplibre/patternRegistry.js | 48 ++++ plugins/beta/datasets/src/api/hideFeatures.js | 4 +- plugins/beta/datasets/src/api/hideRule.js | 4 + .../beta/datasets/src/api/removeDataset.js | 4 +- plugins/beta/datasets/src/api/setStyle.js | 4 +- plugins/beta/datasets/src/api/showFeatures.js | 8 +- plugins/beta/datasets/src/api/showRule.js | 4 + plugins/beta/datasets/src/datasets.js | 4 +- .../datasets/src/fetch/createDynamicSource.js | 20 +- plugins/beta/datasets/src/fillPatterns.js | 9 - plugins/beta/datasets/src/manifest.js | 10 +- plugins/beta/datasets/src/panels/Key.jsx | 95 +++++--- plugins/beta/datasets/src/panels/Layers.jsx | 87 ++++++- .../datasets/src/panels/Layers.module.scss | 13 + plugins/beta/datasets/src/reducer.js | 42 +++- plugins/beta/datasets/src/styles/patterns.js | 16 +- plugins/beta/datasets/src/utils/mergeRule.js | 58 +++++ 21 files changed, 618 insertions(+), 219 deletions(-) create mode 100644 plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js create mode 100644 plugins/beta/datasets/src/adapters/maplibre/layerIds.js rename plugins/beta/datasets/src/adapters/maplibre/{adapter.js => maplibreLayerAdapter.js} (54%) create mode 100644 plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js create mode 100644 plugins/beta/datasets/src/api/hideRule.js create mode 100644 plugins/beta/datasets/src/api/showRule.js delete mode 100644 plugins/beta/datasets/src/fillPatterns.js create mode 100644 plugins/beta/datasets/src/utils/mergeRule.js diff --git a/plugins/beta/datasets/src/adapters/maplibre/index.js b/plugins/beta/datasets/src/adapters/maplibre/index.js index 2e02b115..d9c3a8bf 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/index.js +++ b/plugins/beta/datasets/src/adapters/maplibre/index.js @@ -14,5 +14,5 @@ * }) */ export const maplibreLayerAdapter = { - load: () => import(/* webpackChunkName: "im-datasets-ml-adapter" */ './adapter.js') + load: () => import(/* webpackChunkName: "im-datasets-ml-adapter" */ './maplibreLayerAdapter.js') } diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js new file mode 100644 index 00000000..f0e7fe48 --- /dev/null +++ b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js @@ -0,0 +1,113 @@ +import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' +import { hasPattern, getPatternImageId } from '../../styles/patterns.js' +import { mergeRule } from '../../utils/mergeRule.js' +import { getSourceId, getLayerIds, getRuleLayerIds, isDynamicSource, MAX_TILE_ZOOM } from './layerIds.js' + +// ─── Source ─────────────────────────────────────────────────────────────────── + +export const addSource = (map, dataset, sourceId) => { + if (map.getSource(sourceId)) { + return + } + if (dataset.tiles) { + map.addSource(sourceId, { + type: 'vector', + tiles: dataset.tiles, + minzoom: dataset.minZoom || 0, + maxzoom: dataset.maxZoom || MAX_TILE_ZOOM + }) + return + } + if (dataset.geojson) { + const initialData = isDynamicSource(dataset) + ? { type: 'FeatureCollection', features: [] } + : dataset.geojson + map.addSource(sourceId, { type: 'geojson', data: initialData }) + } +} + +// ─── Fill layer ─────────────────────────────────────────────────────────────── + +export const addFillLayer = (map, config, layerId, sourceId, sourceLayer, visibility, mapStyleId) => { + if (!layerId || map.getLayer(layerId)) { + return + } + if (!config.fill && !hasPattern(config)) { + return + } + const patternImageId = hasPattern(config) ? getPatternImageId(config, mapStyleId) : null + const paint = patternImageId + ? { 'fill-pattern': patternImageId, 'fill-opacity': config.opacity || 1 } + : { 'fill-color': getValueForStyle(config.fill, mapStyleId), 'fill-opacity': config.opacity || 1 } + map.addLayer({ + id: layerId, + type: 'fill', + source: sourceId, + 'source-layer': sourceLayer, + layout: { visibility }, + paint, + ...(config.filter ? { filter: config.filter } : {}) + }) +} + +// ─── Stroke layer ───────────────────────────────────────────────────────────── + +export const addStrokeLayer = (map, config, layerId, sourceId, sourceLayer, visibility, mapStyleId) => { + if (!layerId || !config.stroke || map.getLayer(layerId)) { + return + } + map.addLayer({ + id: layerId, + type: 'line', + source: sourceId, + 'source-layer': sourceLayer, + layout: { visibility }, + paint: { + 'line-color': getValueForStyle(config.stroke, mapStyleId), + 'line-width': config.strokeWidth || 1, + 'line-opacity': config.opacity || 1, + ...(config.strokeDashArray ? { 'line-dasharray': config.strokeDashArray } : {}) + }, + ...(config.filter ? { filter: config.filter } : {}) + }) +} + +// ─── Dataset layers ─────────────────────────────────────────────────────────── + +const addRuleLayers = (map, dataset, rule, sourceId, sourceLayer, mapStyleId) => { + const merged = mergeRule(dataset, rule) + const { fillLayerId, strokeLayerId } = getRuleLayerIds(dataset.id, rule.id) + const parentHidden = dataset.visibility === 'hidden' + const ruleHidden = dataset.ruleVisibility?.[rule.id] === 'hidden' + const visibility = (parentHidden || ruleHidden) ? 'none' : 'visible' + addFillLayer(map, merged, fillLayerId, sourceId, sourceLayer, visibility, mapStyleId) + addStrokeLayer(map, merged, strokeLayerId, sourceId, sourceLayer, visibility, mapStyleId) +} + +/** + * Add all layers (and source if needed) for a dataset. + * Returns the sourceId so the caller can track the datasetId → sourceId mapping. + * @param {Object} map - MapLibre map instance + * @param {Object} dataset + * @param {string} mapStyleId + * @returns {string} sourceId + */ +export const addDatasetLayers = (map, dataset, mapStyleId) => { + const sourceId = getSourceId(dataset) + addSource(map, dataset, sourceId) + + const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined + + if (dataset.featureStyleRules?.length) { + dataset.featureStyleRules.forEach(rule => { + addRuleLayers(map, dataset, rule, sourceId, sourceLayer, mapStyleId) + }) + return sourceId + } + + const { fillLayerId, strokeLayerId } = getLayerIds(dataset) + const visibility = dataset.visibility === 'hidden' ? 'none' : 'visible' + addFillLayer(map, dataset, fillLayerId, sourceId, sourceLayer, visibility, mapStyleId) + addStrokeLayer(map, dataset, strokeLayerId, sourceId, sourceLayer, visibility, mapStyleId) + return sourceId +} diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerIds.js b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js new file mode 100644 index 00000000..73f0e2fc --- /dev/null +++ b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js @@ -0,0 +1,66 @@ +import { hasPattern } from '../../styles/patterns.js' + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +export const isDynamicSource = (dataset) => + typeof dataset.geojson === 'string' && + !!dataset.idProperty && + typeof dataset.transformRequest === 'function' + +const HASH_BASE = 36 +const MAX_TILE_ZOOM = 22 + +export { MAX_TILE_ZOOM } + +export const hashString = (str) => { + let hash = 0 + for (const ch of str) { + hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0)) + } + return Math.abs(hash).toString(HASH_BASE) +} + +// ─── Source ID ──────────────────────────────────────────────────────────────── + +export const getSourceId = (dataset) => { + if (dataset.tiles) { + const tilesKey = Array.isArray(dataset.tiles) ? dataset.tiles.join(',') : dataset.tiles + return `tiles-${hashString(tilesKey)}` + } + if (dataset.geojson) { + if (isDynamicSource(dataset)) { + return `geojson-dynamic-${dataset.id}` + } + if (typeof dataset.geojson === 'string') { + return `geojson-${hashString(dataset.geojson)}` + } + return `geojson-${dataset.id}` + } + return `source-${dataset.id}` +} + +// ─── Layer IDs ──────────────────────────────────────────────────────────────── + +export const getLayerIds = (dataset) => { + const hasFill = !!dataset.fill || hasPattern(dataset) + const hasStroke = !!dataset.stroke + const fillLayerId = hasFill ? dataset.id : null + const strokeLayerId = hasStroke ? (hasFill ? `${dataset.id}-stroke` : dataset.id) : null + return { fillLayerId, strokeLayerId } +} + +export const getRuleLayerIds = (datasetId, ruleId) => ({ + fillLayerId: `${datasetId}--rule-${ruleId}`, + strokeLayerId: `${datasetId}--rule-${ruleId}-stroke` +}) + +export const getAllLayerIds = (dataset) => { + if (dataset.featureStyleRules?.length) { + return dataset.featureStyleRules.flatMap(rule => { + const { fillLayerId: ruleFill, strokeLayerId: ruleStroke } = getRuleLayerIds(dataset.id, rule.id) + return [ruleStroke, ruleFill] + }) + } + const { fillLayerId, strokeLayerId } = getLayerIds(dataset) + return [strokeLayerId, fillLayerId].filter(Boolean) +} diff --git a/plugins/beta/datasets/src/adapters/maplibre/adapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js similarity index 54% rename from plugins/beta/datasets/src/adapters/maplibre/adapter.js rename to plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index 43731318..873aa58f 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/adapter.js +++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -1,28 +1,14 @@ -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' -import { hasPattern, getPatternImageId, rasterisePattern } from '../../styles/patterns.js' import { applyExclusionFilter } from '../../utils/filters.js' - -const isDynamicSource = (dataset) => - typeof dataset.geojson === 'string' && - !!dataset.idProperty && - typeof dataset.transformRequest === 'function' - -const hashString = (str) => { - const HASH_BASE = 36 - let hash = 0 - for (const ch of str) { - hash = ((hash << 5) - hash) + ch.codePointAt(0) - hash = hash & hash - } - return Math.abs(hash).toString(HASH_BASE) -} +import { getSourceId, getLayerIds, getRuleLayerIds, getAllLayerIds } from './layerIds.js' +import { addDatasetLayers } from './layerBuilders.js' +import { registerPatterns } from './patternRegistry.js' /** * MapLibre GL JS implementation of the LayerAdapter interface for the datasets plugin. * * Owns all map-framework-specific concerns: - * - Source and layer creation (fill + stroke layers per dataset) - * - Pattern image registration (rasterisation + map.addImage) + * - Source and layer creation (delegated to layerBuilders) + * - Pattern image registration (delegated to patternRegistry) * - Visibility toggling, feature filtering, style changes * - Style-change recovery (re-adding layers after basemap swap) */ @@ -42,7 +28,7 @@ export default class MaplibreLayerAdapter { * @returns {Promise} Resolves once the map has processed all layers. */ async init (datasets, mapStyleId) { - await this._registerPatterns(datasets, mapStyleId) + await registerPatterns(this._map, datasets, mapStyleId) datasets.forEach(dataset => this._addLayers(dataset, mapStyleId)) await new Promise(resolve => this._map.once('idle', resolve)) } @@ -54,9 +40,11 @@ export default class MaplibreLayerAdapter { destroy (datasets) { const removedSourceIds = new Set() datasets.forEach(dataset => { - const sourceId = this._getSourceId(dataset) + const sourceId = getSourceId(dataset) this._getLayersUsingSource(sourceId).forEach(layerId => { - if (this._map.getLayer(layerId)) this._map.removeLayer(layerId) + if (this._map.getLayer(layerId)) { + this._map.removeLayer(layerId) + } }) if (!removedSourceIds.has(sourceId) && this._map.getSource(sourceId)) { this._map.removeSource(sourceId) @@ -79,7 +67,7 @@ export default class MaplibreLayerAdapter { // MapLibre wipes all sources/layers on style change — must wait for idle first await new Promise(resolve => this._map.once('idle', resolve)) - await this._registerPatterns(datasets, newStyleId) + await registerPatterns(this._map, datasets, newStyleId) datasets.forEach(dataset => this._addLayers(dataset, newStyleId)) // Re-push cached data for dynamic sources @@ -114,17 +102,16 @@ export default class MaplibreLayerAdapter { * @param {Object[]} allDatasets - Full current dataset list, for shared-source check. */ removeDataset (dataset, allDatasets) { - const { fillLayerId, strokeLayerId } = this._getLayerIds(dataset) - const sourceId = this._getSourceId(dataset) + const sourceId = getSourceId(dataset) - ;[strokeLayerId, fillLayerId].forEach(layerId => { - if (layerId && this._map.getLayer(layerId)) { + getAllLayerIds(dataset).forEach(layerId => { + if (this._map.getLayer(layerId)) { this._map.removeLayer(layerId) } }) const sourceIsShared = allDatasets.some( - d => d.id !== dataset.id && this._getSourceId(d) === sourceId + d => d.id !== dataset.id && getSourceId(d) === sourceId ) if (!sourceIsShared && this._map.getSource(sourceId)) { this._map.removeSource(sourceId) @@ -149,6 +136,36 @@ export default class MaplibreLayerAdapter { this._setDatasetVisibility(datasetId, 'none') } + /** + * Make a single featureStyleRule's layers visible. + * @param {string} datasetId + * @param {string} ruleId + */ + showRule (datasetId, ruleId) { + const { fillLayerId, strokeLayerId } = getRuleLayerIds(datasetId, ruleId) + if (this._map.getLayer(fillLayerId)) { + this._map.setLayoutProperty(fillLayerId, 'visibility', 'visible') + } + if (this._map.getLayer(strokeLayerId)) { + this._map.setLayoutProperty(strokeLayerId, 'visibility', 'visible') + } + } + + /** + * Hide a single featureStyleRule's layers. + * @param {string} datasetId + * @param {string} ruleId + */ + hideRule (datasetId, ruleId) { + const { fillLayerId, strokeLayerId } = getRuleLayerIds(datasetId, ruleId) + if (this._map.getLayer(fillLayerId)) { + this._map.setLayoutProperty(fillLayerId, 'visibility', 'none') + } + if (this._map.getLayer(strokeLayerId)) { + this._map.setLayoutProperty(strokeLayerId, 'visibility', 'none') + } + } + // ─── Feature operations ───────────────────────────────────────────────────── /** @@ -171,7 +188,7 @@ export default class MaplibreLayerAdapter { this._applyFeatureFilter(dataset, idProperty, allHiddenIds) } - // ─── New API stubs ─────────────────────────────────────────────────────────── + // ─── API stubs ─────────────────────────────────────────────────────────────── /** * Update a dataset's style properties (fill, stroke, opacity, pattern etc). @@ -191,57 +208,51 @@ export default class MaplibreLayerAdapter { */ setData (datasetId, geojson) { const sourceId = this._datasetSourceMap.get(datasetId) - if (!sourceId) return + if (!sourceId) { + return + } const source = this._map.getSource(sourceId) if (source && typeof source.setData === 'function') { source.setData(geojson) } } - // ─── Private helpers ───────────────────────────────────────────────────────── - - _getSourceId (dataset) { - if (dataset.tiles) { - const tilesKey = Array.isArray(dataset.tiles) ? dataset.tiles.join(',') : dataset.tiles - return `tiles-${hashString(tilesKey)}` - } - if (dataset.geojson) { - if (isDynamicSource(dataset)) return `geojson-dynamic-${dataset.id}` - if (typeof dataset.geojson === 'string') return `geojson-${hashString(dataset.geojson)}` - return `geojson-${dataset.id}` - } - return `source-${dataset.id}` - } - - _getLayerIds (dataset) { - const hasFill = !!dataset.fill || hasPattern(dataset) - const hasStroke = !!dataset.stroke - const fillLayerId = hasFill ? dataset.id : null - const strokeLayerId = hasStroke ? (hasFill ? `${dataset.id}-stroke` : dataset.id) : null - return { fillLayerId, strokeLayerId } - } + // ─── Private ───────────────────────────────────────────────────────────────── - _getLayersUsingSource (sourceId) { - const style = this._map.getStyle() - if (!style?.layers) return [] - return style.layers - .filter(layer => layer.source === sourceId) - .map(layer => layer.id) + _addLayers (dataset, mapStyleId) { + const sourceId = addDatasetLayers(this._map, dataset, mapStyleId) + this._datasetSourceMap.set(dataset.id, sourceId) } _setDatasetVisibility (datasetId, visibility) { - const fillLayerId = datasetId - const strokeLayerId = `${datasetId}-stroke` - if (this._map.getLayer(fillLayerId)) { - this._map.setLayoutProperty(fillLayerId, 'visibility', visibility) - } - if (this._map.getLayer(strokeLayerId)) { - this._map.setLayoutProperty(strokeLayerId, 'visibility', visibility) + const style = this._map.getStyle() + if (!style?.layers) { + return } + // Covers both base layers (datasetId, ${datasetId}-stroke) and rule layers + // (${datasetId}--rule-*) without needing the dataset object. + style.layers + .filter(layer => + layer.id === datasetId || + layer.id === `${datasetId}-stroke` || + layer.id.startsWith(`${datasetId}--rule-`) + ) + .forEach(layer => this._map.setLayoutProperty(layer.id, 'visibility', visibility)) } _applyFeatureFilter (dataset, idProperty, excludeIds) { - const { fillLayerId, strokeLayerId } = this._getLayerIds(dataset) + if (dataset.featureStyleRules?.length) { + dataset.featureStyleRules.forEach(rule => { + const { fillLayerId: ruleFillId, strokeLayerId: ruleStrokeId } = getRuleLayerIds(dataset.id, rule.id) + const ruleFilter = dataset.filter && rule.filter + ? ['all', dataset.filter, rule.filter] + : (rule.filter || dataset.filter || null) + applyExclusionFilter(this._map, ruleFillId, ruleFilter, idProperty, excludeIds) + applyExclusionFilter(this._map, ruleStrokeId, ruleFilter, idProperty, excludeIds) + }) + return + } + const { fillLayerId, strokeLayerId } = getLayerIds(dataset) const originalFilter = dataset.filter || null if (fillLayerId) { applyExclusionFilter(this._map, fillLayerId, originalFilter, idProperty, excludeIds) @@ -251,82 +262,13 @@ export default class MaplibreLayerAdapter { } } - async _registerPatterns (datasets, mapStyleId) { - const patternDatasets = datasets.filter(hasPattern) - if (!patternDatasets.length) return - - await Promise.all(patternDatasets.map(async (dataset) => { - const imageId = getPatternImageId(dataset, mapStyleId) - if (!imageId || this._map.hasImage(imageId)) return - - const result = await rasterisePattern(dataset, mapStyleId) - if (result) { - this._map.addImage(result.imageId, result.imageData, { pixelRatio: 2 }) - } - })) - } - - _addLayers (dataset, mapStyleId) { - const sourceId = this._getSourceId(dataset) - - // Track datasetId → sourceId for setData() - this._datasetSourceMap.set(dataset.id, sourceId) - - // ── Add source ──────────────────────────────────────────────────────────── - if (!this._map.getSource(sourceId)) { - if (dataset.tiles) { - this._map.addSource(sourceId, { - type: 'vector', - tiles: dataset.tiles, - minzoom: dataset.minZoom || 0, - maxzoom: dataset.maxZoom || 22 - }) - } else if (dataset.geojson) { - const initialData = isDynamicSource(dataset) - ? { type: 'FeatureCollection', features: [] } - : dataset.geojson - this._map.addSource(sourceId, { type: 'geojson', data: initialData }) - } - } - - const { fillLayerId, strokeLayerId } = this._getLayerIds(dataset) - const visibility = dataset.visibility === 'hidden' ? 'none' : 'visible' - const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined - - // ── Add fill layer ──────────────────────────────────────────────────────── - if (fillLayerId && !this._map.getLayer(fillLayerId)) { - const patternImageId = hasPattern(dataset) ? getPatternImageId(dataset, mapStyleId) : null - const fillPaint = patternImageId - ? { 'fill-pattern': patternImageId, 'fill-opacity': dataset.opacity || 1 } - : { 'fill-color': getValueForStyle(dataset.fill, mapStyleId), 'fill-opacity': dataset.opacity || 1 } - - this._map.addLayer({ - id: fillLayerId, - type: 'fill', - source: sourceId, - 'source-layer': sourceLayer, - layout: { visibility }, - paint: fillPaint, - ...(dataset.filter ? { filter: dataset.filter } : {}) - }) - } - - // ── Add stroke layer ────────────────────────────────────────────────────── - if (strokeLayerId && !this._map.getLayer(strokeLayerId)) { - this._map.addLayer({ - id: strokeLayerId, - type: 'line', - source: sourceId, - 'source-layer': sourceLayer, - layout: { visibility }, - paint: { - 'line-color': getValueForStyle(dataset.stroke, mapStyleId), - 'line-width': dataset.strokeWidth || 1, - 'line-opacity': dataset.opacity || 1, - ...(dataset.strokeDashArray ? { 'line-dasharray': dataset.strokeDashArray } : {}) - }, - ...(dataset.filter ? { filter: dataset.filter } : {}) - }) + _getLayersUsingSource (sourceId) { + const style = this._map.getStyle() + if (!style?.layers) { + return [] } + return style.layers + .filter(layer => layer.source === sourceId) + .map(layer => layer.id) } } diff --git a/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js b/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js new file mode 100644 index 00000000..0d3a811f --- /dev/null +++ b/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js @@ -0,0 +1,48 @@ +import { hasPattern, getPatternImageId, rasterisePattern } from '../../styles/patterns.js' +import { mergeRule } from '../../utils/mergeRule.js' + +/** + * Collect all style configs that require a pattern image: top-level datasets + * and any featureStyleRules whose merged style has a pattern. + * @param {Object[]} datasets + * @returns {Object[]} + */ +const getPatternConfigs = (datasets) => + datasets.flatMap(dataset => { + const configs = hasPattern(dataset) ? [dataset] : [] + if (dataset.featureStyleRules?.length) { + dataset.featureStyleRules.forEach(rule => { + const merged = mergeRule(dataset, rule) + if (hasPattern(merged)) { + configs.push(merged) + } + }) + } + return configs + }) + +/** + * Register all required pattern images with the map. + * Skips images that are already registered (safe to call on style change). + * @param {Object} map - MapLibre map instance + * @param {Object[]} datasets + * @param {string} mapStyleId + * @returns {Promise} + */ +export const registerPatterns = async (map, datasets, mapStyleId) => { + const patternConfigs = getPatternConfigs(datasets) + if (!patternConfigs.length) { + return + } + + await Promise.all(patternConfigs.map(async (config) => { + const imageId = getPatternImageId(config, mapStyleId) + if (!imageId || map.hasImage(imageId)) { + return + } + const result = await rasterisePattern(config, mapStyleId) + if (result) { + map.addImage(result.imageId, result.imageData, { pixelRatio: 2 }) + } + })) +} diff --git a/plugins/beta/datasets/src/api/hideFeatures.js b/plugins/beta/datasets/src/api/hideFeatures.js index 414d6e42..b2486ed8 100644 --- a/plugins/beta/datasets/src/api/hideFeatures.js +++ b/plugins/beta/datasets/src/api/hideFeatures.js @@ -1,6 +1,8 @@ export const hideFeatures = ({ pluginState }, { featureIds, idProperty, datasetId }) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return + if (!dataset) { + return + } const existingHidden = pluginState.hiddenFeatures[datasetId] const allHiddenIds = existingHidden diff --git a/plugins/beta/datasets/src/api/hideRule.js b/plugins/beta/datasets/src/api/hideRule.js new file mode 100644 index 00000000..2d075982 --- /dev/null +++ b/plugins/beta/datasets/src/api/hideRule.js @@ -0,0 +1,4 @@ +export const hideRule = ({ pluginState }, { datasetId, ruleId }) => { + pluginState.layerAdapter?.hideRule(datasetId, ruleId) + pluginState.dispatch({ type: 'SET_RULE_VISIBILITY', payload: { datasetId, ruleId, visibility: 'hidden' } }) +} diff --git a/plugins/beta/datasets/src/api/removeDataset.js b/plugins/beta/datasets/src/api/removeDataset.js index 2903e8f8..705ea729 100644 --- a/plugins/beta/datasets/src/api/removeDataset.js +++ b/plugins/beta/datasets/src/api/removeDataset.js @@ -1,6 +1,8 @@ export const removeDataset = ({ pluginState }, datasetId) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return + if (!dataset) { + return + } pluginState.layerAdapter?.removeDataset(dataset, pluginState.datasets) pluginState.dispatch({ type: 'REMOVE_DATASET', payload: { id: datasetId } }) diff --git a/plugins/beta/datasets/src/api/setStyle.js b/plugins/beta/datasets/src/api/setStyle.js index 1446a96c..57ed1bfb 100644 --- a/plugins/beta/datasets/src/api/setStyle.js +++ b/plugins/beta/datasets/src/api/setStyle.js @@ -1,6 +1,8 @@ export const setStyle = ({ pluginState, mapState }, { datasetId, ...styleChanges }) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return + if (!dataset) { + return + } // TODO: dispatch state update for style changes pluginState.layerAdapter?.setStyle(dataset, mapState.mapStyle.id, styleChanges) diff --git a/plugins/beta/datasets/src/api/showFeatures.js b/plugins/beta/datasets/src/api/showFeatures.js index 1868c047..2ec14b23 100644 --- a/plugins/beta/datasets/src/api/showFeatures.js +++ b/plugins/beta/datasets/src/api/showFeatures.js @@ -1,9 +1,13 @@ export const showFeatures = ({ pluginState }, { featureIds, idProperty, datasetId }) => { const existingHidden = pluginState.hiddenFeatures[datasetId] - if (!existingHidden) return + if (!existingHidden) { + return + } const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return + if (!dataset) { + return + } const remainingHiddenIds = existingHidden.ids.filter(id => !featureIds.includes(id)) diff --git a/plugins/beta/datasets/src/api/showRule.js b/plugins/beta/datasets/src/api/showRule.js new file mode 100644 index 00000000..6bb4e253 --- /dev/null +++ b/plugins/beta/datasets/src/api/showRule.js @@ -0,0 +1,4 @@ +export const showRule = ({ pluginState }, { datasetId, ruleId }) => { + pluginState.layerAdapter?.showRule(datasetId, ruleId) + pluginState.dispatch({ type: 'SET_RULE_VISIBILITY', payload: { datasetId, ruleId, visibility: 'visible' } }) +} diff --git a/plugins/beta/datasets/src/datasets.js b/plugins/beta/datasets/src/datasets.js index 8833065a..b179f134 100644 --- a/plugins/beta/datasets/src/datasets.js +++ b/plugins/beta/datasets/src/datasets.js @@ -24,7 +24,9 @@ export const createDatasets = ({ // Initialise all datasets via the adapter, then set up dynamic sources adapter.init(datasets, mapStyleId).then(() => { datasets.forEach(dataset => { - if (!isDynamicSource(dataset)) return + if (!isDynamicSource(dataset)) { + return + } const dynamicSource = createDynamicSource({ dataset, diff --git a/plugins/beta/datasets/src/fetch/createDynamicSource.js b/plugins/beta/datasets/src/fetch/createDynamicSource.js index 707a35ad..63d1417e 100644 --- a/plugins/beta/datasets/src/fetch/createDynamicSource.js +++ b/plugins/beta/datasets/src/fetch/createDynamicSource.js @@ -94,15 +94,21 @@ export const createDynamicSource = ({ dataset, map, onUpdate }) => { */ const fetchData = async () => { const zoom = map.getZoom() - if (zoom < minZoom) return + if (zoom < minZoom) { + return + } const currentBbox = getBboxArray(map) // Skip if current viewport is already covered - if (fetchedBbox && bboxContains(fetchedBbox, currentBbox)) return + if (fetchedBbox && bboxContains(fetchedBbox, currentBbox)) { + return + } // Abort any in-flight request — new viewport takes priority - if (currentController) currentController.abort() + if (currentController) { + currentController.abort() + } currentController = new AbortController() try { @@ -140,7 +146,9 @@ export const createDynamicSource = ({ dataset, map, onUpdate }) => { // Update map source onUpdate(dataset.id, toFeatureCollection()) } catch (error) { - if (error.name === 'AbortError') return + if (error.name === 'AbortError') { + return + } console.error(`Failed to fetch dynamic GeoJSON for ${dataset.id}:`, error) } } @@ -165,7 +173,9 @@ export const createDynamicSource = ({ dataset, map, onUpdate }) => { destroy () { map.off('moveend', handleMoveEnd) debouncedFetch.cancel() - if (currentController) currentController.abort() + if (currentController) { + currentController.abort() + } }, /** diff --git a/plugins/beta/datasets/src/fillPatterns.js b/plugins/beta/datasets/src/fillPatterns.js deleted file mode 100644 index 42104565..00000000 --- a/plugins/beta/datasets/src/fillPatterns.js +++ /dev/null @@ -1,9 +0,0 @@ -// Re-exports from styles/patterns.js for backward compatibility. -// Map-registration logic (registerPatternImages) now lives in the layer adapter. -export { - hasPattern, - getPatternInnerContent, - getPatternImageId, - getKeyPatternPaths, - rasterisePattern -} from './styles/patterns.js' diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index 9f4d350f..c0a71a6e 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -9,6 +9,8 @@ import { addDataset } from './api/addDataset.js' import { removeDataset } from './api/removeDataset.js' import { showFeatures } from './api/showFeatures.js' import { hideFeatures } from './api/hideFeatures.js' +import { showRule } from './api/showRule.js' +import { hideRule } from './api/hideRule.js' import { setStyle } from './api/setStyle.js' import { setData } from './api/setData.js' @@ -65,7 +67,9 @@ export const manifest = { label: 'Layers', panelId: 'datasetsLayers', iconId: 'layers', - excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.find(l => l.toggleVisibility), + excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l => + l.toggleVisibility || l.featureStyleRules?.some(r => r.toggleVisibility) + ), mobile: { slot: 'top-left', showLabel: true @@ -83,7 +87,7 @@ export const manifest = { label: 'Key', panelId: 'datasetsKey', iconId: 'key', - excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.find(l => l.showInKey), + excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l => l.showInKey), mobile: { slot: 'top-left', showLabel: false @@ -113,6 +117,8 @@ export const manifest = { removeDataset, showFeatures, hideFeatures, + showRule, + hideRule, setStyle, setData } diff --git a/plugins/beta/datasets/src/panels/Key.jsx b/plugins/beta/datasets/src/panels/Key.jsx index 80fa15f5..26456c3f 100755 --- a/plugins/beta/datasets/src/panels/Key.jsx +++ b/plugins/beta/datasets/src/panels/Key.jsx @@ -1,55 +1,60 @@ import React from 'react' import { getValueForStyle } from '../../../../../src/utils/getValueForStyle' -import { hasPattern, getKeyPatternPaths } from '../fillPatterns.js' +import { hasPattern, getKeyPatternPaths } from '../styles/patterns.js' +import { mergeRule } from '../utils/mergeRule.js' + +const SVG_SIZE = 20 +const SVG_CENTER = SVG_SIZE / 2 +const PATTERN_INSET = 2 export const Key = ({ mapState, pluginState }) => { const { mapStyle } = mapState - const itemSymbol = (dataset) => { + const itemSymbol = (config) => { const svgProps = { xmlns: 'http://www.w3.org/2000/svg', - width: '20', - height: '20', - viewBox: '0 0 20 20', + width: SVG_SIZE, + height: SVG_SIZE, + viewBox: `0 0 ${SVG_SIZE} ${SVG_SIZE}`, 'aria-hidden': 'true', focusable: 'false' } - if (hasPattern(dataset)) { - const paths = getKeyPatternPaths(dataset, mapStyle.id) + if (hasPattern(config)) { + const paths = getKeyPatternPaths(config, mapStyle.id) return ( - + ) } return ( - {dataset.keySymbolShape === 'line' + {config.keySymbolShape === 'line' ? ( ) : ( )} @@ -57,19 +62,39 @@ export const Key = ({ mapState, pluginState }) => { ) } + const itemLabel = (config) => ( +
+ {itemSymbol(config)} + {config.label} + {config.symbolDescription && ( + + ({getValueForStyle(config.symbolDescription, mapStyle.id)}) + + )} +
+ ) + + // Build a flat list of { key, config } entries — one per rule for datasets + // with featureStyleRules, or one per dataset otherwise. + const keyEntries = (pluginState.datasets || []) + .filter(dataset => dataset.showInKey && dataset.visibility !== 'hidden') + .flatMap(dataset => { + if (dataset.featureStyleRules?.length) { + return dataset.featureStyleRules + .filter(rule => dataset.ruleVisibility?.[rule.id] !== 'hidden') + .map(rule => ({ + key: `${dataset.id}--rule-${rule.id}`, + config: mergeRule(dataset, rule) + })) + } + return [{ key: dataset.id, config: dataset }] + }) + return (
- {(pluginState.datasets || []).filter(dataset => dataset.showInKey && dataset.visibility !== 'hidden').map(dataset => ( -
-
- {itemSymbol(dataset)} - {dataset.label} - {dataset.symbolDescription && ( - - ({getValueForStyle(dataset.symbolDescription, mapStyle.id)}) - - )} -
+ {keyEntries.map(({ key, config }) => ( +
+ {itemLabel(config)}
))}
diff --git a/plugins/beta/datasets/src/panels/Layers.jsx b/plugins/beta/datasets/src/panels/Layers.jsx index c4102421..c39a3dea 100755 --- a/plugins/beta/datasets/src/panels/Layers.jsx +++ b/plugins/beta/datasets/src/panels/Layers.jsx @@ -1,9 +1,16 @@ import React from 'react' import { showDataset } from '../api/showDataset' import { hideDataset } from '../api/hideDataset' +import { showRule } from '../api/showRule' +import { hideRule } from '../api/hideRule' + +const hasToggleableRules = (dataset) => + dataset.featureStyleRules?.some(rule => rule.toggleVisibility) + +const CHECKBOX_LABEL_CLASS = 'im-c-datasets-layers__item-label govuk-label govuk-checkboxes__label' export const Layers = ({ pluginState }) => { - const handleChange = (e) => { + const handleDatasetChange = (e) => { const { value, checked } = e.target if (checked) { showDataset({ pluginState }, value) @@ -12,6 +19,17 @@ export const Layers = ({ pluginState }) => { } } + const handleRuleChange = (e) => { + const { checked } = e.target + const datasetId = e.target.dataset.datasetId + const ruleId = e.target.dataset.ruleId + if (checked) { + showRule({ pluginState }, { datasetId, ruleId }) + } else { + hideRule({ pluginState }, { datasetId, ruleId }) + } + } + return (
@@ -20,16 +38,63 @@ export const Layers = ({ pluginState }) => { Layers
- {(pluginState.datasets || []).filter(dataset => dataset.toggleVisibility).map(dataset => ( -
-
- - -
-
- ))} + {(pluginState.datasets || []) + .filter(dataset => dataset.toggleVisibility || hasToggleableRules(dataset)) + .map(dataset => { + if (hasToggleableRules(dataset)) { + return ( +
+
{dataset.label}
+ {dataset.featureStyleRules + .filter(rule => rule.toggleVisibility) + .map(rule => { + const ruleVisible = dataset.ruleVisibility?.[rule.id] !== 'hidden' + const inputId = `${dataset.id}--rule-${rule.id}` + const ruleItemClass = `im-c-datasets-layers__item${ruleVisible ? ' im-c-datasets-layers__item--checked' : ''}` + return ( +
+
+ + +
+
+ ) + })} +
+ ) + } + + const datasetItemClass = `im-c-datasets-layers__item${dataset.visibility === 'hidden' ? '' : ' im-c-datasets-layers__item--checked'}` + return ( +
+
+ + +
+
+ ) + })}
diff --git a/plugins/beta/datasets/src/panels/Layers.module.scss b/plugins/beta/datasets/src/panels/Layers.module.scss index 9a48e776..9a900c9f 100644 --- a/plugins/beta/datasets/src/panels/Layers.module.scss +++ b/plugins/beta/datasets/src/panels/Layers.module.scss @@ -31,3 +31,16 @@ margin-left: -3px; } } + +.im-c-datasets-layers__group { + &:not(:last-child) { + margin-bottom: 5px; + } +} + +.im-c-datasets-layers__group-label { + padding: 8px 10px 4px; + font-size: 0.875rem; + font-weight: bold; + color: var(--foreground-color); +} diff --git a/plugins/beta/datasets/src/reducer.js b/plugins/beta/datasets/src/reducer.js index 8c3205f2..19a55136 100755 --- a/plugins/beta/datasets/src/reducer.js +++ b/plugins/beta/datasets/src/reducer.js @@ -4,11 +4,22 @@ const initialState = { layerAdapter: null } +const initRuleVisibility = (dataset) => { + if (!dataset.featureStyleRules?.length) { + return dataset + } + const ruleVisibility = {} + dataset.featureStyleRules.forEach(rule => { + ruleVisibility[rule.id] = 'visible' + }) + return { ...dataset, ruleVisibility } +} + const setDatasets = (state, payload) => { const { datasets, datasetDefaults } = payload return { ...state, - datasets: datasets.map(dataset => ({ + datasets: datasets.map(dataset => initRuleVisibility({ ...datasetDefaults, ...dataset })) @@ -21,7 +32,7 @@ const addDataset = (state, payload) => { ...state, datasets: [ ...(state.datasets || []), - { ...datasetDefaults, ...dataset } + initRuleVisibility({ ...datasetDefaults, ...dataset }) ] } } @@ -62,12 +73,15 @@ const hideFeatures = (state, payload) => { const showFeatures = (state, payload) => { const { layerId, featureIds } = payload const existing = state.hiddenFeatures[layerId] - if (!existing) return state + if (!existing) { + return state + } const newIds = existing.ids.filter(id => !featureIds.includes(id)) if (newIds.length === 0) { - const { [layerId]: _, ...rest } = state.hiddenFeatures + const rest = { ...state.hiddenFeatures } + delete rest[layerId] return { ...state, hiddenFeatures: rest } } @@ -80,6 +94,25 @@ const showFeatures = (state, payload) => { } } +const setRuleVisibility = (state, payload) => { + const { datasetId, ruleId, visibility } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => { + if (dataset.id !== datasetId) { + return dataset + } + return { + ...dataset, + ruleVisibility: { + ...dataset.ruleVisibility, + [ruleId]: visibility + } + } + }) + } +} + const setLayerAdapter = (state, payload) => ({ ...state, layerAdapter: payload }) const actions = { @@ -87,6 +120,7 @@ const actions = { ADD_DATASET: addDataset, REMOVE_DATASET: removeDataset, SET_DATASET_VISIBILITY: setDatasetVisibility, + SET_RULE_VISIBILITY: setRuleVisibility, HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, SET_LAYER_ADAPTER: setLayerAdapter diff --git a/plugins/beta/datasets/src/styles/patterns.js b/plugins/beta/datasets/src/styles/patterns.js index e262fd6b..c74d5b53 100644 --- a/plugins/beta/datasets/src/styles/patterns.js +++ b/plugins/beta/datasets/src/styles/patterns.js @@ -52,7 +52,9 @@ export const hasPattern = (dataset) => !!(dataset.fillPattern || dataset.fillPat * @returns {string|null} */ export const getPatternInnerContent = (dataset) => { - if (dataset.fillPatternSvgContent) return dataset.fillPatternSvgContent + if (dataset.fillPatternSvgContent) { + return dataset.fillPatternSvgContent + } if (dataset.fillPattern && BUILT_IN_PATTERNS[dataset.fillPattern]) { return BUILT_IN_PATTERNS[dataset.fillPattern] } @@ -67,7 +69,9 @@ export const getPatternInnerContent = (dataset) => { */ export const getPatternImageId = (dataset, mapStyleId) => { const innerContent = getPatternInnerContent(dataset) - if (!innerContent) return null + if (!innerContent) { + return null + } const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black' const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent' return `pattern-${hashString(innerContent + fg + bg)}` @@ -82,7 +86,9 @@ export const getPatternImageId = (dataset, mapStyleId) => { */ export const getKeyPatternPaths = (dataset, mapStyleId) => { const innerContent = getPatternInnerContent(dataset) - if (!innerContent) return null + if (!innerContent) { + return null + } const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black' const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent' const borderStroke = getValueForStyle(dataset.stroke, mapStyleId) || fg @@ -129,7 +135,9 @@ const rasteriseToImageData = (svgString, width, height) => */ export const rasterisePattern = async (dataset, mapStyleId) => { const innerContent = getPatternInnerContent(dataset) - if (!innerContent) return null + if (!innerContent) { + return null + } const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black' const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent' diff --git a/plugins/beta/datasets/src/utils/mergeRule.js b/plugins/beta/datasets/src/utils/mergeRule.js new file mode 100644 index 00000000..7196282c --- /dev/null +++ b/plugins/beta/datasets/src/utils/mergeRule.js @@ -0,0 +1,58 @@ +/** + * Merge a featureStyleRule with its parent dataset, producing a flat style + * object suitable for layer creation and key symbol rendering. + * + * Fill precedence (highest to lowest): + * 1. Rule's own fillPattern + * 2. Rule's own fill (explicit, even if transparent — clears any parent pattern) + * 3. Parent's fillPattern + * 4. Parent's fill + * + * All other style props fall back to the parent dataset if not set on the rule. + */ +export const mergeRule = (dataset, rule) => { + const ruleHasPattern = !!(rule.fillPattern || rule.fillPatternSvgContent) + const ruleHasExplicitFill = 'fill' in rule + + let fillProps + if (ruleHasPattern) { + fillProps = { + fillPattern: rule.fillPattern, + fillPatternSvgContent: rule.fillPatternSvgContent, + fillPatternForegroundColor: rule.fillPatternForegroundColor ?? dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: rule.fillPatternBackgroundColor ?? dataset.fillPatternBackgroundColor + } + } else if (ruleHasExplicitFill) { + // Rule explicitly sets a plain fill — do not inherit any parent pattern + fillProps = { fill: rule.fill } + } else { + fillProps = { + fill: dataset.fill, + fillPattern: dataset.fillPattern, + fillPatternSvgContent: dataset.fillPatternSvgContent, + fillPatternForegroundColor: dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: dataset.fillPatternBackgroundColor + } + } + + const combinedFilter = dataset.filter && rule.filter + ? ['all', dataset.filter, rule.filter] + : (rule.filter || dataset.filter || null) + + return { + id: rule.id, + label: rule.label, + stroke: rule.stroke ?? dataset.stroke, + strokeWidth: rule.strokeWidth ?? dataset.strokeWidth, + strokeDashArray: rule.strokeDashArray ?? dataset.strokeDashArray, + opacity: rule.opacity ?? dataset.opacity, + keySymbolShape: rule.keySymbolShape ?? dataset.keySymbolShape, + symbolDescription: rule.symbolDescription ?? dataset.symbolDescription, + showInKey: rule.showInKey ?? dataset.showInKey, + toggleVisibility: rule.toggleVisibility ?? false, + filter: combinedFilter, + minZoom: dataset.minZoom, + maxZoom: dataset.maxZoom, + ...fillProps + } +} From efdaf3bd36c0bcbb75aa38cc36e22445284b6c6f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 25 Mar 2026 21:12:48 +0000 Subject: [PATCH 02/14] Sonar rule fix --- .vscode/settings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e681853..543a902a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,13 @@ "sonarlint.connectedMode.project": { "connectionId": "defra", "projectKey": "DEFRA_interactive-map" + }, + "sonarlint.rules": { + "javascript:S6774": { + "level": "off" + }, + "javascript:S100": { + "level": "off" + } } } \ No newline at end of file From 6c87082399ac97c37d333fa664e0d7bd8ef468ff Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 16:55:02 +0000 Subject: [PATCH 03/14] Feature style rules plus panel styling --- demo/js/draw.js | 28 +-- demo/js/index.js | 115 ++++++++--- .../src/adapters/maplibre/layerBuilders.js | 2 +- .../src/adapters/maplibre/layerIds.js | 9 +- .../adapters/maplibre/maplibreLayerAdapter.js | 16 +- plugins/beta/datasets/src/api/hideRule.js | 2 +- plugins/beta/datasets/src/api/showRule.js | 2 +- plugins/beta/datasets/src/datasets.js | 7 +- plugins/beta/datasets/src/defaults.js | 39 +++- plugins/beta/datasets/src/manifest.js | 8 +- plugins/beta/datasets/src/panels/Key.jsx | 98 ++++++--- .../beta/datasets/src/panels/Key.module.scss | 57 +++++- plugins/beta/datasets/src/panels/Layers.jsx | 191 +++++++++++------- .../datasets/src/panels/Layers.module.scss | 59 ++++-- plugins/beta/datasets/src/reducer.js | 9 +- plugins/beta/datasets/src/styles/patterns.js | 2 +- plugins/beta/datasets/src/utils/bbox.js | 12 +- plugins/beta/datasets/src/utils/filters.js | 3 +- plugins/beta/datasets/src/utils/mergeRule.js | 90 +++++---- .../maplibre/src/utils/highlightFeatures.js | 1 + 20 files changed, 506 insertions(+), 244 deletions(-) diff --git a/demo/js/draw.js b/demo/js/draw.js index c176c9ea..8ffa636c 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -72,19 +72,21 @@ const datasetsPlugin = createDatasetsPlugin({ // showInKey: true, // toggleVisibility: true, // visibility: 'hidden', - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - strokeWidth: 2, - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], - // fill: 'rgba(0,0,255,0.1)', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', - opacity: 0.5 + opacity: 0.5, + // style: { + stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + strokeWidth: 2, + // symbol: '', + // symbolSvgContent: '', + // symbolForegroundColor: '', + // symbolBackgroundColor: '', + // symbolDescription: { outdoor: 'blue outline' }, + // symbolOffset: [], + // fill: 'rgba(0,0,255,0.1)', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent' + // } }] }) diff --git a/demo/js/index.js b/demo/js/index.js index b23d3098..93988772 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -29,11 +29,16 @@ import createFramePlugin from '/plugins/beta/frame/src/index.js' const pointData = {type: 'FeatureCollection','features': [{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.882445487962059,54.70938250564518],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8775970686837695,54.70966586215056],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8732152153681056,54.70892223300439],'type': 'Point'}}]} const interactPlugin = createInteractPlugin({ - dataLayers: [{ - layerId: 'field-parcels', + dataLayers: [ + // { + // layerId: 'field-parcels-130', + // // idProperty: 'gid' + // }, + { + layerId: 'field-parcels-332', // idProperty: 'gid' },{ - layerId: 'linked-parcels', + layerId: 'field-parcels-other', // idProperty: 'gid' },{ layerId: 'OS/TopographicArea_1/Agricultural Land', @@ -110,39 +115,91 @@ const datasetsPlugin = createDatasetsPlugin({ showInKey: true, toggleVisibility: true, // visibility: 'hidden', - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - strokeWidth: 2, - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], - // fill: 'rgba(0,0,255,0.1)', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', + style: { + stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + strokeWidth: 2, + // symbol: '', + // symbolSvgContent: '', + // symbolForegroundColor: '', + // symbolBackgroundColor: '', + // symbolDescription: { outdoor: 'blue outline' }, + // symbolOffset: [], + // fill: 'rgba(0,0,255,0.1)', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent' + }, featureStyleRules: [{ + id: '130', + label: 'Permanent grassland', + filter: ['==', ['get', 'dominant_land_cover'], '130'], + toggleVisibility: true, + style: { + stroke: { outdoor: '#82F584', dark: '#ffffff' }, + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#82F584', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent' + } + },{ id: '332', label: 'Woodland', filter: ['==', ['get', 'dominant_land_cover'], '332'], - stroke: { outdoor: '#00ff00', dark: '#ffffff' }, - fillPattern: 'cross-hatch', - fillPatternForegroundColor: { outdoor: '#00ff00', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', - toggleVisibility: true + toggleVisibility: true, + style: { + stroke: { outdoor: '#66CA7A', dark: '#ffffff' }, + fillPattern: 'dot', + fillPatternForegroundColor: { outdoor: '#66CA7A', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent' + } },{ id: 'other', label: 'Others', - filter: ['!=', ['get', 'dominant_land_cover'], '332'], - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - fill: 'rgba(0,0,255,0.1)', - // fillPattern: 'cross-hatch', - // fillPatternForegroundColor: { outdoor: '#00ff00', dark: '#ffffff' }, - // fillPatternBackgroundColor: 'transparent', - toggleVisibility: true + filter: ['!', ['in', ['get', 'dominant_land_cover'], ['literal', ['130', '332']]]], + toggleVisibility: true, + style: { + stroke: { outdoor: ' #1d70b8', dark: '#ffffff' }, + fill: 'rgba(0,0,255,0.1)', + fillPattern: 'vertical-hatch', + fillPatternForegroundColor: { outdoor: '#1d70b8', dark: '#ffffff' }, + // fillPatternBackgroundColor: 'transparent' + } }], opacity: 0.5 + },{ + id: 'hedge-control', + label: 'Hedge control', + // groupLabel: 'Test group', + tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'], + sourceLayer: 'hedge_control', + minZoom: 10, + maxZoom: 24, + showInKey: true, + toggleVisibility: true, + visibility: 'hidden', + keySymbolShape: 'line', + style: { + stroke: '#b58840', + fill: 'transparent', + strokeWidth: 4, + symbolDescription: { outdoor: 'blue outline' } + } + },{ + id: 'linked-parcels', + label: 'Existing fields', + // groupLabel: 'Test group', + filter: ['all',['==', ['get', 'sbi'], '106223377'],['==', ['get', 'is_dominant_land_cover'], true]], + tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'], + sourceLayer: 'field_parcels_filtered', + minZoom: 10, + maxZoom: 24, + showInKey: true, + toggleVisibility: true, + style: { + stroke: '#0000ff', + strokeWidth: 2, + fill: 'rgba(0,0,255,0.1)', + symbolDescription: { outdoor: 'blue outline' } + } }] }) @@ -217,8 +274,8 @@ interactiveMap.on('map:ready', function (e) { }) interactiveMap.on('datasets:ready', function () { - // setTimeout(() => datasetsPlugin.hideDataset('field-parcels'), 2000) - // setTimeout(() => datasetsPlugin.showDataset('field-parcels'), 4000) + // setTimeout(() => datasetsPlugin.hideFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 2000) + // setTimeout(() => datasetsPlugin.showFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 4000) }) // Ref to the selected features diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js index f0e7fe48..976980df 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +++ b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js @@ -22,7 +22,7 @@ export const addSource = (map, dataset, sourceId) => { const initialData = isDynamicSource(dataset) ? { type: 'FeatureCollection', features: [] } : dataset.geojson - map.addSource(sourceId, { type: 'geojson', data: initialData }) + map.addSource(sourceId, { type: 'geojson', data: initialData, generateId: true }) } } diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerIds.js b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js index 73f0e2fc..f66eacb4 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +++ b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js @@ -45,13 +45,16 @@ export const getLayerIds = (dataset) => { const hasFill = !!dataset.fill || hasPattern(dataset) const hasStroke = !!dataset.stroke const fillLayerId = hasFill ? dataset.id : null - const strokeLayerId = hasStroke ? (hasFill ? `${dataset.id}-stroke` : dataset.id) : null + let strokeLayerId = null + if (hasStroke) { + strokeLayerId = hasFill ? `${dataset.id}-stroke` : dataset.id + } return { fillLayerId, strokeLayerId } } export const getRuleLayerIds = (datasetId, ruleId) => ({ - fillLayerId: `${datasetId}--rule-${ruleId}`, - strokeLayerId: `${datasetId}--rule-${ruleId}-stroke` + fillLayerId: `${datasetId}-${ruleId}`, + strokeLayerId: `${datasetId}-${ruleId}-stroke` }) export const getAllLayerIds = (dataset) => { diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index 873aa58f..8d8a2f18 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -110,9 +110,8 @@ export default class MaplibreLayerAdapter { } }) - const sourceIsShared = allDatasets.some( - d => d.id !== dataset.id && getSourceId(d) === sourceId - ) + const sourceIsShared = allDatasets.some(d => d.id !== dataset.id && getSourceId(d) === sourceId) + if (!sourceIsShared && this._map.getSource(sourceId)) { this._map.removeSource(sourceId) } @@ -197,8 +196,8 @@ export default class MaplibreLayerAdapter { * @param {Object} styleChanges * @stub */ - setStyle (dataset, mapStyleId, styleChanges) { - // TODO: implement — map.setPaintProperty for fill-color, line-color, opacity etc + setStyle (_dataset, _mapStyleId, _styleChanges) { + // Not yet implemented — will use map.setPaintProperty for fill-color, line-color, opacity etc } /** @@ -229,13 +228,12 @@ export default class MaplibreLayerAdapter { if (!style?.layers) { return } - // Covers both base layers (datasetId, ${datasetId}-stroke) and rule layers - // (${datasetId}--rule-*) without needing the dataset object. + // Covers base fill layer (datasetId) and all suffixed layers + // (-stroke, -${ruleId}, -${ruleId}-stroke) without needing the dataset object. style.layers .filter(layer => layer.id === datasetId || - layer.id === `${datasetId}-stroke` || - layer.id.startsWith(`${datasetId}--rule-`) + layer.id.startsWith(`${datasetId}-`) ) .forEach(layer => this._map.setLayoutProperty(layer.id, 'visibility', visibility)) } diff --git a/plugins/beta/datasets/src/api/hideRule.js b/plugins/beta/datasets/src/api/hideRule.js index 2d075982..883a45f3 100644 --- a/plugins/beta/datasets/src/api/hideRule.js +++ b/plugins/beta/datasets/src/api/hideRule.js @@ -1,4 +1,4 @@ -export const hideRule = ({ pluginState }, { datasetId, ruleId }) => { +export const hideRule = ({ pluginState }, datasetId, ruleId) => { pluginState.layerAdapter?.hideRule(datasetId, ruleId) pluginState.dispatch({ type: 'SET_RULE_VISIBILITY', payload: { datasetId, ruleId, visibility: 'hidden' } }) } diff --git a/plugins/beta/datasets/src/api/showRule.js b/plugins/beta/datasets/src/api/showRule.js index 6bb4e253..4230f2ac 100644 --- a/plugins/beta/datasets/src/api/showRule.js +++ b/plugins/beta/datasets/src/api/showRule.js @@ -1,4 +1,4 @@ -export const showRule = ({ pluginState }, { datasetId, ruleId }) => { +export const showRule = ({ pluginState }, datasetId, ruleId) => { pluginState.layerAdapter?.showRule(datasetId, ruleId) pluginState.dispatch({ type: 'SET_RULE_VISIBILITY', payload: { datasetId, ruleId, visibility: 'visible' } }) } diff --git a/plugins/beta/datasets/src/datasets.js b/plugins/beta/datasets/src/datasets.js index b179f134..f14d3879 100644 --- a/plugins/beta/datasets/src/datasets.js +++ b/plugins/beta/datasets/src/datasets.js @@ -1,4 +1,6 @@ import { createDynamicSource } from './fetch/createDynamicSource.js' +// NOSONAR: applyDatasetDefaults and datasetDefaults are used in processedDatasets.map +import { applyDatasetDefaults, datasetDefaults } from './defaults.js' const isDynamicSource = (dataset) => typeof dataset.geojson === 'string' && @@ -22,8 +24,9 @@ export const createDatasets = ({ const getHiddenFeatures = () => pluginStateRef.current.hiddenFeatures || {} // Initialise all datasets via the adapter, then set up dynamic sources - adapter.init(datasets, mapStyleId).then(() => { - datasets.forEach(dataset => { + const processedDatasets = datasets.map(d => applyDatasetDefaults(d, datasetDefaults)) + adapter.init(processedDatasets, mapStyleId).then(() => { + processedDatasets.forEach(dataset => { if (!isDynamicSource(dataset)) { return } diff --git a/plugins/beta/datasets/src/defaults.js b/plugins/beta/datasets/src/defaults.js index bee0c428..8e4670de 100644 --- a/plugins/beta/datasets/src/defaults.js +++ b/plugins/beta/datasets/src/defaults.js @@ -1,15 +1,40 @@ const datasetDefaults = { - stroke: '#d4351c', - strokeWidth: 2, - fill: 'transparent', - symbolDescription: 'red outline', minZoom: 6, maxZoom: 24, showInKey: false, toggleVisibility: false, - visibility: 'visible' + visibility: 'visible', + style: { + stroke: '#d4351c', + strokeWidth: 2, + fill: 'transparent', + symbolDescription: 'red outline' + } } -export { - datasetDefaults +// Props whose presence in a style object indicates a custom visual style. +// When any are set, the default symbolDescription is not appropriate. +const VISUAL_STYLE_PROPS = ['stroke', 'fill', 'fillPattern', 'fillPatternSvgContent'] + +const hasCustomVisualStyle = (style) => + VISUAL_STYLE_PROPS.some(prop => prop in style) + +/** + * Merge a dataset config with defaults, flattening the nested `style` object. + * symbolDescription from defaults.style is dropped when custom visual styles + * are present and the dataset doesn't explicitly set its own symbolDescription. + */ +const applyDatasetDefaults = (dataset, defaults) => { + const style = dataset.style || {} + const mergedStyle = { ...defaults.style, ...style } + if (!('symbolDescription' in style) && hasCustomVisualStyle(style)) { + delete mergedStyle.symbolDescription + } + const topLevel = { ...dataset } + delete topLevel.style + const topLevelDefaults = { ...defaults } + delete topLevelDefaults.style + return { ...topLevelDefaults, ...topLevel, ...mergedStyle } } + +export { datasetDefaults, hasCustomVisualStyle, applyDatasetDefaults } diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index c0a71a6e..c5dce464 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -34,14 +34,14 @@ export const manifest = { slot: 'left-top', dismissible: true, exclusive: true, - width: '300px' + width: '260px' }, desktop: { slot: 'left-top', modal: false, dismissible: true, exclusive: true, - width: '320px' + width: '280px' }, render: Layers }, { @@ -53,11 +53,11 @@ export const manifest = { }, tablet: { slot: 'left-top', - width: '300px' + width: '260px' }, desktop: { slot: 'left-top', - width: '320px' + width: '280px' }, render: Key }], diff --git a/plugins/beta/datasets/src/panels/Key.jsx b/plugins/beta/datasets/src/panels/Key.jsx index 26456c3f..bfcc805f 100755 --- a/plugins/beta/datasets/src/panels/Key.jsx +++ b/plugins/beta/datasets/src/panels/Key.jsx @@ -7,6 +7,31 @@ const SVG_SIZE = 20 const SVG_CENTER = SVG_SIZE / 2 const PATTERN_INSET = 2 +const buildKeyGroups = (datasets) => { + const seenGroups = new Set() + const items = [] + datasets.forEach(dataset => { + if (dataset.featureStyleRules?.length) { + items.push({ type: 'rules', dataset }) + return + } + if (dataset.groupLabel) { + if (seenGroups.has(dataset.groupLabel)) { + return + } + seenGroups.add(dataset.groupLabel) + items.push({ + type: 'group', + groupLabel: dataset.groupLabel, + datasets: datasets.filter(d => !d.featureStyleRules?.length && d.groupLabel === dataset.groupLabel) + }) + return + } + items.push({ type: 'flat', dataset }) + }) + return items +} + export const Key = ({ mapState, pluginState }) => { const { mapStyle } = mapState @@ -62,41 +87,54 @@ export const Key = ({ mapState, pluginState }) => { ) } - const itemLabel = (config) => ( -
- {itemSymbol(config)} - {config.label} - {config.symbolDescription && ( - - ({getValueForStyle(config.symbolDescription, mapStyle.id)}) - - )} -
+ const renderEntry = (key, config) => ( +
+
{itemSymbol(config)}
+
+ {config.label} + {config.symbolDescription && ( + + ({getValueForStyle(config.symbolDescription, mapStyle.id)}) + + )} +
+
) - // Build a flat list of { key, config } entries — one per rule for datasets - // with featureStyleRules, or one per dataset otherwise. - const keyEntries = (pluginState.datasets || []) + const visibleDatasets = (pluginState.datasets || []) .filter(dataset => dataset.showInKey && dataset.visibility !== 'hidden') - .flatMap(dataset => { - if (dataset.featureStyleRules?.length) { - return dataset.featureStyleRules - .filter(rule => dataset.ruleVisibility?.[rule.id] !== 'hidden') - .map(rule => ({ - key: `${dataset.id}--rule-${rule.id}`, - config: mergeRule(dataset, rule) - })) - } - return [{ key: dataset.id, config: dataset }] - }) + + const keyGroups = buildKeyGroups(visibleDatasets) + const hasGroups = keyGroups.some(item => item.type === 'rules' || item.type === 'group') + const containerClass = `im-c-datasets-key${hasGroups ? ' im-c-datasets-key--has-groups' : ''}` return ( -
- {keyEntries.map(({ key, config }) => ( -
- {itemLabel(config)} -
- ))} +
+ {keyGroups.map(item => { + if (item.type === 'rules') { + const headingId = `key-heading-${item.dataset.id}` + return ( +
+

{item.dataset.label}

+ {item.dataset.featureStyleRules + .filter(rule => item.dataset.ruleVisibility?.[rule.id] !== 'hidden') + .map(rule => renderEntry(`${item.dataset.id}-${rule.id}`, mergeRule(item.dataset, rule)))} +
+ ) + } + + if (item.type === 'group') { + const headingId = `key-heading-${item.groupLabel.toLowerCase().replaceAll(/\s+/g, '-')}` + return ( +
+

{item.groupLabel}

+ {item.datasets.map(dataset => renderEntry(dataset.id, dataset))} +
+ ) + } + + return renderEntry(item.dataset.id, item.dataset) + })}
) } diff --git a/plugins/beta/datasets/src/panels/Key.module.scss b/plugins/beta/datasets/src/panels/Key.module.scss index 9f5f7798..aa2a2185 100644 --- a/plugins/beta/datasets/src/panels/Key.module.scss +++ b/plugins/beta/datasets/src/panels/Key.module.scss @@ -1,23 +1,58 @@ -.im-c-datasets-key { +// When groups are present, every direct child gets a border-top +.im-c-datasets-key--has-groups > * { + border-top: 1px solid var(--button-hover-color); +} + +// When no groups, only the first child gets a border-top +.im-c-datasets-key:not(.im-c-datasets-key--has-groups) > *:first-child { + border-top: 1px solid var(--button-hover-color); +} +.im-c-datasets-key__group:not(:last-child) { + padding-bottom: 5px; +} + +.im-c-datasets-key__group-heading { + padding-top: 15px; + padding-bottom: 10px; + margin: 0; + font-size: 1rem; + font-weight: bold; + color: var(--foreground-color); } -.im-c-datasets-key__item-label { +.im-c-datasets-key__item { display: flex; align-items: start; - padding-top: 12px; - padding-bottom: 12px; - align-self: auto; + padding-top: 10px; + padding-bottom: 10px; font-size: 1rem; - line-height: 1.2; + + // When mixed with groups, flat items match group heading spacing + .im-c-datasets-key--has-groups > & { + padding-top: 15px; + padding-bottom: 15px; + } + + // First item inside a group — heading already provides top spacing + .im-c-datasets-key__group &:first-child { + padding-top: 0; + } } -.im-c-datasets-key__item:last-child .im-c-datasets-key__item-label { - padding-bottom: 2px; +// No-groups: first item needs 15px below the single border +.im-c-datasets-key:not(.im-c-datasets-key--has-groups) > .im-c-datasets-key__item:first-child { + padding-top: 15px; } -.im-c-datasets-key__item-label svg { +// Last item in all scenarios — flat or inside the last group +.im-c-datasets-key > .im-c-datasets-key__item:last-child, +.im-c-datasets-key__group:last-child .im-c-datasets-key__item:last-child { + padding-bottom: 5px; +} + +.im-c-datasets-key__item svg { position: relative; flex-shrink: 0; - margin: 0px 13px 0 2px; -} \ No newline at end of file + margin: 0 13px 0 2px; +} diff --git a/plugins/beta/datasets/src/panels/Layers.jsx b/plugins/beta/datasets/src/panels/Layers.jsx index c39a3dea..b68065d2 100755 --- a/plugins/beta/datasets/src/panels/Layers.jsx +++ b/plugins/beta/datasets/src/panels/Layers.jsx @@ -4,11 +4,41 @@ import { hideDataset } from '../api/hideDataset' import { showRule } from '../api/showRule' import { hideRule } from '../api/hideRule' -const hasToggleableRules = (dataset) => - dataset.featureStyleRules?.some(rule => rule.toggleVisibility) - const CHECKBOX_LABEL_CLASS = 'im-c-datasets-layers__item-label govuk-label govuk-checkboxes__label' +const hasToggleableRules = (dataset) => dataset.featureStyleRules?.some(rule => rule.toggleVisibility) + +/** + * Collapse the filtered dataset list into ordered render items: + * { type: 'rules', dataset } — dataset with featureStyleRules (takes precedence) + * { type: 'group', groupLabel, datasets } — datasets sharing a groupLabel + * { type: 'flat', dataset } — standalone dataset + */ +const buildRenderItems = (datasets) => { + const seenGroups = new Set() + const items = [] + datasets.forEach(dataset => { + if (hasToggleableRules(dataset)) { + items.push({ type: 'rules', dataset }) + return + } + if (dataset.groupLabel) { + if (seenGroups.has(dataset.groupLabel)) { + return + } + seenGroups.add(dataset.groupLabel) + items.push({ + type: 'group', + groupLabel: dataset.groupLabel, + datasets: datasets.filter(d => !hasToggleableRules(d) && d.groupLabel === dataset.groupLabel) + }) + return + } + items.push({ type: 'flat', dataset }) + }) + return items +} + export const Layers = ({ pluginState }) => { const handleDatasetChange = (e) => { const { value, checked } = e.target @@ -24,80 +54,99 @@ export const Layers = ({ pluginState }) => { const datasetId = e.target.dataset.datasetId const ruleId = e.target.dataset.ruleId if (checked) { - showRule({ pluginState }, { datasetId, ruleId }) + showRule({ pluginState }, datasetId, ruleId) } else { - hideRule({ pluginState }, { datasetId, ruleId }) + hideRule({ pluginState }, datasetId, ruleId) } } + const renderDatasetItem = (dataset) => { + const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${dataset.visibility === 'hidden' ? '' : ' im-c-datasets-layers__item--checked'}` + return ( +
+
+ + +
+
+ ) + } + + const visibleDatasets = (pluginState.datasets || []) + .filter(dataset => dataset.toggleVisibility || hasToggleableRules(dataset)) + + const renderItems = buildRenderItems(visibleDatasets) + const hasGroups = renderItems.some(item => item.type === 'rules' || item.type === 'group') + const containerClass = `im-c-datasets-layers${hasGroups ? ' im-c-datasets-layers--has-groups' : ''}` + return ( -
-
-
- - Layers - -
- {(pluginState.datasets || []) - .filter(dataset => dataset.toggleVisibility || hasToggleableRules(dataset)) - .map(dataset => { - if (hasToggleableRules(dataset)) { - return ( -
-
{dataset.label}
- {dataset.featureStyleRules - .filter(rule => rule.toggleVisibility) - .map(rule => { - const ruleVisible = dataset.ruleVisibility?.[rule.id] !== 'hidden' - const inputId = `${dataset.id}--rule-${rule.id}` - const ruleItemClass = `im-c-datasets-layers__item${ruleVisible ? ' im-c-datasets-layers__item--checked' : ''}` - return ( -
-
- - -
-
- ) - })} -
- ) - } +
+ {renderItems.map(item => { + if (item.type === 'rules') { + const { dataset } = item + const anyRuleChecked = dataset.featureStyleRules + .filter(rule => rule.toggleVisibility) + .some(rule => dataset.ruleVisibility?.[rule.id] !== 'hidden') + const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anyRuleChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` + return ( +
+
+ {dataset.label} + {dataset.featureStyleRules + .filter(rule => rule.toggleVisibility) + .map(rule => { + const ruleVisible = dataset.ruleVisibility?.[rule.id] !== 'hidden' + const inputId = `${dataset.id}-${rule.id}` + const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${ruleVisible ? ' im-c-datasets-layers__item--checked' : ''}` + return ( +
+
+ + +
+
+ ) + })} +
+
+ ) + } - const datasetItemClass = `im-c-datasets-layers__item${dataset.visibility === 'hidden' ? '' : ' im-c-datasets-layers__item--checked'}` - return ( -
-
- - -
-
- ) - })} -
-
-
+ if (item.type === 'group') { + const anyDatasetChecked = item.datasets.some(d => d.visibility !== 'hidden') + const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anyDatasetChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` + return ( +
+
+ {item.groupLabel} + {item.datasets.map(dataset => renderDatasetItem(dataset))} +
+
+ ) + } + + return renderDatasetItem(item.dataset) + })}
) } diff --git a/plugins/beta/datasets/src/panels/Layers.module.scss b/plugins/beta/datasets/src/panels/Layers.module.scss index 9a900c9f..71735204 100644 --- a/plugins/beta/datasets/src/panels/Layers.module.scss +++ b/plugins/beta/datasets/src/panels/Layers.module.scss @@ -1,8 +1,37 @@ +// When groups are present, every direct child (groups and flat items) gets a border-top +.im-c-datasets-layers--has-groups > * { + border-top: 1px solid var(--button-hover-color); +} + +// When no groups, only the first child gets a border-top with 15px padding +.im-c-datasets-layers:not(.im-c-datasets-layers--has-groups) > *:first-child { + border-top: 1px solid var(--button-hover-color); + padding-top: 10px; +} + +.im-c-datasets-layers > *:last-child { + margin-bottom: -5px; +} + +.im-c-datasets-layers-group:not(:last-child) { + padding-bottom: 5px; +} + .im-c-datasets-layers__item { - border: 1px solid var(--button-hover-color); - padding-left: 10px; - &:not(:last-child) { - margin-bottom: 5px; + padding-bottom: 5px; + + .im-c-datasets-layers--has-groups > & { + padding-top: 5px; + } + + // Items inside a group have no individual padding or spacing between them + .im-c-datasets-layers-group & { + padding-top: 0; + padding-bottom: 0; + + &:not(:last-child) { + margin-bottom: 0; + } } } @@ -19,10 +48,6 @@ color: var(--foreground-color); } -.im-c-datasets-layers__item--checked { - border-color: var(--app-border-color); -} - // GovUK style overide .im-c-datasets-layers__item .govuk-checkboxes__item { flex-wrap: nowrap; @@ -32,15 +57,19 @@ } } -.im-c-datasets-layers__group { - &:not(:last-child) { - margin-bottom: 5px; - } +.im-c-datasets-layers-group__fieldset { + // Reset browser fieldset defaults + border: none; + padding: 0; + margin: 0; + min-width: 0; } -.im-c-datasets-layers__group-label { - padding: 8px 10px 4px; - font-size: 0.875rem; +.im-c-datasets-layers-group__legend { + padding-top: 15px; + padding-bottom: 10px; + padding-inline: 0; + font-size: 1rem; font-weight: bold; color: var(--foreground-color); } diff --git a/plugins/beta/datasets/src/reducer.js b/plugins/beta/datasets/src/reducer.js index 19a55136..87416a02 100755 --- a/plugins/beta/datasets/src/reducer.js +++ b/plugins/beta/datasets/src/reducer.js @@ -1,3 +1,5 @@ +import { applyDatasetDefaults } from './defaults.js' + const initialState = { datasets: null, hiddenFeatures: {}, // { [layerId]: { idProperty: string, ids: string[] } } @@ -19,10 +21,7 @@ const setDatasets = (state, payload) => { const { datasets, datasetDefaults } = payload return { ...state, - datasets: datasets.map(dataset => initRuleVisibility({ - ...datasetDefaults, - ...dataset - })) + datasets: datasets.map(dataset => initRuleVisibility(applyDatasetDefaults(dataset, datasetDefaults))) } } @@ -32,7 +31,7 @@ const addDataset = (state, payload) => { ...state, datasets: [ ...(state.datasets || []), - initRuleVisibility({ ...datasetDefaults, ...dataset }) + initRuleVisibility(applyDatasetDefaults(dataset, datasetDefaults)) ] } } diff --git a/plugins/beta/datasets/src/styles/patterns.js b/plugins/beta/datasets/src/styles/patterns.js index c74d5b53..7671c2b7 100644 --- a/plugins/beta/datasets/src/styles/patterns.js +++ b/plugins/beta/datasets/src/styles/patterns.js @@ -28,7 +28,7 @@ export const hashString = (str) => { hash = ((hash << 5) - hash) + ch.codePointAt(0) hash = hash & hash } - return Math.abs(hash).toString(36) + return Math.abs(hash).toString(36) // NOSONAR: base36 encoding for compact alphanumeric hash string } export const injectColors = (content, foreground, background) => diff --git a/plugins/beta/datasets/src/utils/bbox.js b/plugins/beta/datasets/src/utils/bbox.js index f6cd47d1..bcb0d55e 100755 --- a/plugins/beta/datasets/src/utils/bbox.js +++ b/plugins/beta/datasets/src/utils/bbox.js @@ -22,7 +22,7 @@ export const bboxContains = (outer, inner) => { inner[0] >= outer[0] && // west inner[1] >= outer[1] && // south inner[2] <= outer[2] && // east - inner[3] <= outer[3] // north + inner[3] <= outer[3] // NOSONAR, north ) } @@ -40,7 +40,7 @@ export const expandBbox = (existing, addition) => { Math.min(existing[0], addition[0]), // west Math.min(existing[1], addition[1]), // south Math.max(existing[2], addition[2]), // east - Math.max(existing[3], addition[3]) // north + Math.max(existing[3], addition[3]) // NOSONAR, north ] } @@ -57,8 +57,8 @@ export const bboxIntersects = (a, b) => { return !( a[2] < b[0] || // a is left of b a[0] > b[2] || // a is right of b - a[3] < b[1] || // a is below b - a[1] > b[3] // a is above b + a[3] < b[1] || // NOSONAR a is below b + a[1] > b[3] // NOSONAR a is above b ) } @@ -98,7 +98,7 @@ export const getGeometryBbox = (geometry) => { processCoords(geometry.coordinates, 2) break case 'MultiPolygon': - processCoords(geometry.coordinates, 3) + processCoords(geometry.coordinates, 3) // NOSONAR: 3 = coordinate nesting depth for MultiPolygon ([polygons][rings][points]) break case 'GeometryCollection': geometry.geometries.forEach(g => { @@ -109,6 +109,8 @@ export const getGeometryBbox = (geometry) => { maxY = Math.max(maxY, b[3]) }) break + default: + throw new Error(`Unsupported geometry type: ${geometry.type}`) } return [minX, minY, maxX, maxY] diff --git a/plugins/beta/datasets/src/utils/filters.js b/plugins/beta/datasets/src/utils/filters.js index fc48be3b..b4ce35a5 100755 --- a/plugins/beta/datasets/src/utils/filters.js +++ b/plugins/beta/datasets/src/utils/filters.js @@ -10,7 +10,8 @@ export const buildExclusionFilter = (originalFilter, idProperty, excludeIds) => // Coerce both sides to strings to handle number/string type mismatches // When no idProperty, use feature-level id via ['id'] (GeoJSON feature.id) const idExpr = idProperty ? ['to-string', ['get', idProperty]] : ['to-string', ['id']] - const stringIds = excludeIds.map(id => String(id)) + // Convert all IDs to strings; map passes each element as the first argument to String + const stringIds = excludeIds.map(String) const exclusionFilter = ['!', ['in', idExpr, ['literal', stringIds]]] if (!originalFilter) { diff --git a/plugins/beta/datasets/src/utils/mergeRule.js b/plugins/beta/datasets/src/utils/mergeRule.js index 7196282c..203ed77e 100644 --- a/plugins/beta/datasets/src/utils/mergeRule.js +++ b/plugins/beta/datasets/src/utils/mergeRule.js @@ -1,58 +1,78 @@ +import { hasCustomVisualStyle } from '../defaults.js' + +const getFillProps = (dataset, ruleStyle) => { + if (ruleStyle.fillPattern || ruleStyle.fillPatternSvgContent) { + return { + fillPattern: ruleStyle.fillPattern, + fillPatternSvgContent: ruleStyle.fillPatternSvgContent, + fillPatternForegroundColor: ruleStyle.fillPatternForegroundColor ?? dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: ruleStyle.fillPatternBackgroundColor ?? dataset.fillPatternBackgroundColor + } + } + if ('fill' in ruleStyle) { + // Rule explicitly sets a plain fill — do not inherit any parent pattern + return { fill: ruleStyle.fill } + } + return { + fill: dataset.fill, + fillPattern: dataset.fillPattern, + fillPatternSvgContent: dataset.fillPatternSvgContent, + fillPatternForegroundColor: dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: dataset.fillPatternBackgroundColor + } +} + +const getCombinedFilter = (datasetFilter, ruleFilter) => { + if (datasetFilter && ruleFilter) { + return ['all', datasetFilter, ruleFilter] + } + return ruleFilter || datasetFilter || null +} + +const getSymbolDescription = (dataset, ruleStyle) => { + if ('symbolDescription' in ruleStyle) { + return ruleStyle.symbolDescription + } + if (hasCustomVisualStyle(ruleStyle)) { + return undefined + } + return dataset.symbolDescription +} + /** * Merge a featureStyleRule with its parent dataset, producing a flat style * object suitable for layer creation and key symbol rendering. * + * The rule's nested `style` object is flattened before merging. + * * Fill precedence (highest to lowest): * 1. Rule's own fillPattern * 2. Rule's own fill (explicit, even if transparent — clears any parent pattern) * 3. Parent's fillPattern * 4. Parent's fill * - * All other style props fall back to the parent dataset if not set on the rule. + * symbolDescription is only inherited from the parent when the rule has no + * custom visual styles of its own. If the rule overrides stroke/fill/pattern + * without setting symbolDescription explicitly, no description is shown. */ export const mergeRule = (dataset, rule) => { - const ruleHasPattern = !!(rule.fillPattern || rule.fillPatternSvgContent) - const ruleHasExplicitFill = 'fill' in rule - - let fillProps - if (ruleHasPattern) { - fillProps = { - fillPattern: rule.fillPattern, - fillPatternSvgContent: rule.fillPatternSvgContent, - fillPatternForegroundColor: rule.fillPatternForegroundColor ?? dataset.fillPatternForegroundColor, - fillPatternBackgroundColor: rule.fillPatternBackgroundColor ?? dataset.fillPatternBackgroundColor - } - } else if (ruleHasExplicitFill) { - // Rule explicitly sets a plain fill — do not inherit any parent pattern - fillProps = { fill: rule.fill } - } else { - fillProps = { - fill: dataset.fill, - fillPattern: dataset.fillPattern, - fillPatternSvgContent: dataset.fillPatternSvgContent, - fillPatternForegroundColor: dataset.fillPatternForegroundColor, - fillPatternBackgroundColor: dataset.fillPatternBackgroundColor - } - } - - const combinedFilter = dataset.filter && rule.filter - ? ['all', dataset.filter, rule.filter] - : (rule.filter || dataset.filter || null) + const ruleStyle = rule.style || {} + const combinedFilter = getCombinedFilter(dataset.filter, rule.filter) return { id: rule.id, label: rule.label, - stroke: rule.stroke ?? dataset.stroke, - strokeWidth: rule.strokeWidth ?? dataset.strokeWidth, - strokeDashArray: rule.strokeDashArray ?? dataset.strokeDashArray, - opacity: rule.opacity ?? dataset.opacity, - keySymbolShape: rule.keySymbolShape ?? dataset.keySymbolShape, - symbolDescription: rule.symbolDescription ?? dataset.symbolDescription, + stroke: ruleStyle.stroke ?? dataset.stroke, + strokeWidth: ruleStyle.strokeWidth ?? dataset.strokeWidth, + strokeDashArray: ruleStyle.strokeDashArray ?? dataset.strokeDashArray, + opacity: ruleStyle.opacity ?? dataset.opacity, + keySymbolShape: ruleStyle.keySymbolShape ?? dataset.keySymbolShape, + symbolDescription: getSymbolDescription(dataset, ruleStyle), showInKey: rule.showInKey ?? dataset.showInKey, toggleVisibility: rule.toggleVisibility ?? false, filter: combinedFilter, minZoom: dataset.minZoom, maxZoom: dataset.maxZoom, - ...fillProps + ...getFillProps(dataset, ruleStyle) } } diff --git a/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js index 6b56c815..411b8e0b 100755 --- a/providers/maplibre/src/utils/highlightFeatures.js +++ b/providers/maplibre/src/utils/highlightFeatures.js @@ -60,6 +60,7 @@ const applyHighlightLayer = (map, id, type, sourceId, srcLayer, paint, filter) = map.setPaintProperty(id, prop, value) }) map.setFilter(id, filter) + map.moveLayer(id) } const calculateBounds = (LngLatBounds, renderedFeatures) => { From 4d0b6e18747506643e8576a16fdfd4790d899165 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 20:40:21 +0000 Subject: [PATCH 04/14] Demo updated --- demo/js/index.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index 93988772..1fcc1791 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -29,12 +29,10 @@ import createFramePlugin from '/plugins/beta/frame/src/index.js' const pointData = {type: 'FeatureCollection','features': [{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.882445487962059,54.70938250564518],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8775970686837695,54.70966586215056],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8732152153681056,54.70892223300439],'type': 'Point'}}]} const interactPlugin = createInteractPlugin({ - dataLayers: [ - // { - // layerId: 'field-parcels-130', - // // idProperty: 'gid' - // }, - { + dataLayers: [{ + layerId: 'field-parcels-130', + // idProperty: 'gid' + },{ layerId: 'field-parcels-332', // idProperty: 'gid' },{ From c72f72039c1dd287b2f22140104d9428847addd5 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 20:54:04 +0000 Subject: [PATCH 05/14] Style inheritence fix --- demo/js/draw.js | 14 ++++---------- demo/js/index.js | 3 +-- plugins/beta/datasets/src/defaults.js | 9 +++++++++ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/demo/js/draw.js b/demo/js/draw.js index 8ffa636c..2188d549 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -72,21 +72,15 @@ const datasetsPlugin = createDatasetsPlugin({ // showInKey: true, // toggleVisibility: true, // visibility: 'hidden', - opacity: 0.5, - // style: { + style: { stroke: { outdoor: '#0000ff', dark: '#ffffff' }, strokeWidth: 2, - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], // fill: 'rgba(0,0,255,0.1)', fillPattern: 'diagonal-cross-hatch', fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent' - // } + fillPatternBackgroundColor: 'transparent', + opacity: 0.2 + } }] }) diff --git a/demo/js/index.js b/demo/js/index.js index 1fcc1791..b02126a7 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -161,8 +161,7 @@ const datasetsPlugin = createDatasetsPlugin({ fillPatternForegroundColor: { outdoor: '#1d70b8', dark: '#ffffff' }, // fillPatternBackgroundColor: 'transparent' } - }], - opacity: 0.5 + }] },{ id: 'hedge-control', label: 'Hedge control', diff --git a/plugins/beta/datasets/src/defaults.js b/plugins/beta/datasets/src/defaults.js index 8e4670de..7cfc436a 100644 --- a/plugins/beta/datasets/src/defaults.js +++ b/plugins/beta/datasets/src/defaults.js @@ -12,6 +12,13 @@ const datasetDefaults = { } } +// All properties considered style properties — must be provided via dataset.style, not at the top level. +const STYLE_PROPS = [ + 'stroke', 'strokeWidth', 'strokeDashArray', + 'fill', 'fillPattern', 'fillPatternSvgContent', 'fillPatternForegroundColor', 'fillPatternBackgroundColor', + 'opacity', 'symbolDescription', 'keySymbolShape' +] + // Props whose presence in a style object indicates a custom visual style. // When any are set, the default symbolDescription is not appropriate. const VISUAL_STYLE_PROPS = ['stroke', 'fill', 'fillPattern', 'fillPatternSvgContent'] @@ -21,6 +28,7 @@ const hasCustomVisualStyle = (style) => /** * Merge a dataset config with defaults, flattening the nested `style` object. + * Style properties must be provided via dataset.style — top-level occurrences are ignored. * symbolDescription from defaults.style is dropped when custom visual styles * are present and the dataset doesn't explicitly set its own symbolDescription. */ @@ -32,6 +40,7 @@ const applyDatasetDefaults = (dataset, defaults) => { } const topLevel = { ...dataset } delete topLevel.style + STYLE_PROPS.forEach(prop => delete topLevel[prop]) const topLevelDefaults = { ...defaults } delete topLevelDefaults.style return { ...topLevelDefaults, ...topLevel, ...mergedStyle } From 2e48fcf867afca38a3bd5d6f9b671f92bd8d825f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 21:38:35 +0000 Subject: [PATCH 06/14] setStyle methods added --- demo/js/index.js | 29 ++++++------ .../src/adapters/maplibre/layerBuilders.js | 2 +- .../adapters/maplibre/maplibreLayerAdapter.js | 44 +++++++++++++++---- plugins/beta/datasets/src/api/setData.js | 8 +++- plugins/beta/datasets/src/api/setRuleStyle.js | 16 +++++++ plugins/beta/datasets/src/api/setStyle.js | 8 ++-- plugins/beta/datasets/src/manifest.js | 2 + plugins/beta/datasets/src/reducer.js | 32 ++++++++++++++ .../src/utils/highlightFeatures.test.js | 1 + src/App/registry/pluginRegistry.js | 2 +- src/App/renderer/mapButtons.js | 2 +- src/App/store/ServiceProvider.jsx | 4 +- src/App/store/appDispatchMiddleware.js | 2 +- src/{utils => services}/logger.js | 0 src/{utils => services}/logger.test.js | 0 15 files changed, 120 insertions(+), 32 deletions(-) create mode 100644 plugins/beta/datasets/src/api/setRuleStyle.js rename src/{utils => services}/logger.js (100%) rename src/{utils => services}/logger.test.js (100%) diff --git a/demo/js/index.js b/demo/js/index.js index b02126a7..8e330bcb 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -113,20 +113,20 @@ const datasetsPlugin = createDatasetsPlugin({ showInKey: true, toggleVisibility: true, // visibility: 'hidden', - style: { - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - strokeWidth: 2, - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], - // fill: 'rgba(0,0,255,0.1)', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent' - }, + // style: { + // stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + // strokeWidth: 2, + // symbol: '', + // symbolSvgContent: '', + // symbolForegroundColor: '', + // symbolBackgroundColor: '', + // symbolDescription: { outdoor: 'blue outline' }, + // symbolOffset: [], + // fill: 'rgba(0,0,255,0.1)', + // fillPattern: 'diagonal-cross-hatch', + // fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + // fillPatternBackgroundColor: 'transparent' + // }, featureStyleRules: [{ id: '130', label: 'Permanent grassland', @@ -273,6 +273,7 @@ interactiveMap.on('map:ready', function (e) { interactiveMap.on('datasets:ready', function () { // setTimeout(() => datasetsPlugin.hideFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 2000) // setTimeout(() => datasetsPlugin.showFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 4000) + // setTimeout(() => datasetsPlugin.setRuleStyle({ datasetId: 'field-parcels', ruleId: '130', style: { stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } } }), 2000) }) // Ref to the selected features diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js index 976980df..d06b167b 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +++ b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js @@ -74,7 +74,7 @@ export const addStrokeLayer = (map, config, layerId, sourceId, sourceLayer, visi // ─── Dataset layers ─────────────────────────────────────────────────────────── -const addRuleLayers = (map, dataset, rule, sourceId, sourceLayer, mapStyleId) => { +export const addRuleLayers = (map, dataset, rule, sourceId, sourceLayer, mapStyleId) => { const merged = mergeRule(dataset, rule) const { fillLayerId, strokeLayerId } = getRuleLayerIds(dataset.id, rule.id) const parentHidden = dataset.visibility === 'hidden' diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index 8d8a2f18..ad025e36 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -1,6 +1,6 @@ import { applyExclusionFilter } from '../../utils/filters.js' import { getSourceId, getLayerIds, getRuleLayerIds, getAllLayerIds } from './layerIds.js' -import { addDatasetLayers } from './layerBuilders.js' +import { addDatasetLayers, addRuleLayers } from './layerBuilders.js' import { registerPatterns } from './patternRegistry.js' /** @@ -187,17 +187,45 @@ export default class MaplibreLayerAdapter { this._applyFeatureFilter(dataset, idProperty, allHiddenIds) } - // ─── API stubs ─────────────────────────────────────────────────────────────── + /** + * Update a dataset's style and re-render all its layers. + * @param {Object} dataset - Updated dataset (style changes already merged in) + * @param {string} mapStyleId + * @returns {Promise} + */ + async setStyle (dataset, mapStyleId) { + getAllLayerIds(dataset).forEach(layerId => { + if (this._map.getLayer(layerId)) { + this._map.removeLayer(layerId) + } + }) + await registerPatterns(this._map, [dataset], mapStyleId) + this._addLayers(dataset, mapStyleId) + } /** - * Update a dataset's style properties (fill, stroke, opacity, pattern etc). - * @param {Object} dataset + * Update a single featureStyleRule's style and re-render its layers. + * @param {Object} dataset - Updated dataset (rule style changes already merged in) + * @param {string} ruleId * @param {string} mapStyleId - * @param {Object} styleChanges - * @stub + * @returns {Promise} */ - setStyle (_dataset, _mapStyleId, _styleChanges) { - // Not yet implemented — will use map.setPaintProperty for fill-color, line-color, opacity etc + async setRuleStyle (dataset, ruleId, mapStyleId) { + const { fillLayerId, strokeLayerId } = getRuleLayerIds(dataset.id, ruleId) + if (this._map.getLayer(fillLayerId)) { + this._map.removeLayer(fillLayerId) + } + if (this._map.getLayer(strokeLayerId)) { + this._map.removeLayer(strokeLayerId) + } + const rule = dataset.featureStyleRules?.find(r => r.id === ruleId) + if (!rule) { + return + } + await registerPatterns(this._map, [dataset], mapStyleId) + const sourceId = this._datasetSourceMap.get(dataset.id) + const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined + addRuleLayers(this._map, dataset, rule, sourceId, sourceLayer, mapStyleId) } /** diff --git a/plugins/beta/datasets/src/api/setData.js b/plugins/beta/datasets/src/api/setData.js index edc85301..6503a892 100644 --- a/plugins/beta/datasets/src/api/setData.js +++ b/plugins/beta/datasets/src/api/setData.js @@ -1,4 +1,8 @@ -export const setData = ({ pluginState }, { datasetId, geojson }) => { - // TODO: dispatch state update if dataset data needs to be tracked in state +export const setData = ({ pluginState, services }, { datasetId, geojson }) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (dataset?.tiles) { + services.logger.warn(`setData called on vector tile dataset "${datasetId}" — has no effect`) + return + } pluginState.layerAdapter?.setData(datasetId, geojson) } diff --git a/plugins/beta/datasets/src/api/setRuleStyle.js b/plugins/beta/datasets/src/api/setRuleStyle.js new file mode 100644 index 00000000..1094ef79 --- /dev/null +++ b/plugins/beta/datasets/src/api/setRuleStyle.js @@ -0,0 +1,16 @@ +export const setRuleStyle = ({ pluginState, mapState }, { datasetId, ruleId, style }) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + + pluginState.dispatch({ type: 'SET_RULE_STYLE', payload: { datasetId, ruleId, styleChanges: style } }) + + const updatedDataset = { + ...dataset, + featureStyleRules: dataset.featureStyleRules?.map(rule => + rule.id === ruleId ? { ...rule, style: { ...rule.style, ...style } } : rule + ) + } + pluginState.layerAdapter?.setRuleStyle(updatedDataset, ruleId, mapState.mapStyle.id) +} diff --git a/plugins/beta/datasets/src/api/setStyle.js b/plugins/beta/datasets/src/api/setStyle.js index 57ed1bfb..07891cc0 100644 --- a/plugins/beta/datasets/src/api/setStyle.js +++ b/plugins/beta/datasets/src/api/setStyle.js @@ -1,9 +1,11 @@ -export const setStyle = ({ pluginState, mapState }, { datasetId, ...styleChanges }) => { +export const setStyle = ({ pluginState, mapState }, { datasetId, style }) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) if (!dataset) { return } - // TODO: dispatch state update for style changes - pluginState.layerAdapter?.setStyle(dataset, mapState.mapStyle.id, styleChanges) + pluginState.dispatch({ type: 'SET_DATASET_STYLE', payload: { datasetId, styleChanges: style } }) + + const updatedDataset = { ...dataset, ...style } + pluginState.layerAdapter?.setStyle(updatedDataset, mapState.mapStyle.id) } diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index c5dce464..11e57aac 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -12,6 +12,7 @@ import { hideFeatures } from './api/hideFeatures.js' import { showRule } from './api/showRule.js' import { hideRule } from './api/hideRule.js' import { setStyle } from './api/setStyle.js' +import { setRuleStyle } from './api/setRuleStyle.js' import { setData } from './api/setData.js' export const manifest = { @@ -120,6 +121,7 @@ export const manifest = { showRule, hideRule, setStyle, + setRuleStyle, setData } } diff --git a/plugins/beta/datasets/src/reducer.js b/plugins/beta/datasets/src/reducer.js index 87416a02..e71ee74f 100755 --- a/plugins/beta/datasets/src/reducer.js +++ b/plugins/beta/datasets/src/reducer.js @@ -112,6 +112,36 @@ const setRuleVisibility = (state, payload) => { } } +const setDatasetStyle = (state, payload) => { + const { datasetId, styleChanges } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => + dataset.id === datasetId ? { ...dataset, ...styleChanges } : dataset + ) + } +} + +const setRuleStyle = (state, payload) => { + const { datasetId, ruleId, styleChanges } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => { + if (dataset.id !== datasetId) { + return dataset + } + return { + ...dataset, + featureStyleRules: dataset.featureStyleRules?.map(rule => + rule.id === ruleId + ? { ...rule, style: { ...rule.style, ...styleChanges } } + : rule + ) + } + }) + } +} + const setLayerAdapter = (state, payload) => ({ ...state, layerAdapter: payload }) const actions = { @@ -120,6 +150,8 @@ const actions = { REMOVE_DATASET: removeDataset, SET_DATASET_VISIBILITY: setDatasetVisibility, SET_RULE_VISIBILITY: setRuleVisibility, + SET_DATASET_STYLE: setDatasetStyle, + SET_RULE_STYLE: setRuleStyle, HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, SET_LAYER_ADAPTER: setLayerAdapter diff --git a/providers/maplibre/src/utils/highlightFeatures.test.js b/providers/maplibre/src/utils/highlightFeatures.test.js index 6bc21e90..162eac46 100644 --- a/providers/maplibre/src/utils/highlightFeatures.test.js +++ b/providers/maplibre/src/utils/highlightFeatures.test.js @@ -16,6 +16,7 @@ describe('Highlighting Utils', () => { _highlightedSources: new Set(['stale']), getLayer: jest.fn(), addLayer: jest.fn(), + moveLayer: jest.fn(), setFilter: jest.fn(), setPaintProperty: jest.fn(), queryRenderedFeatures: jest.fn() diff --git a/src/App/registry/pluginRegistry.js b/src/App/registry/pluginRegistry.js index 64b46dfb..a5fbc24e 100755 --- a/src/App/registry/pluginRegistry.js +++ b/src/App/registry/pluginRegistry.js @@ -2,7 +2,7 @@ import { registerIcon } from './iconRegistry.js' import { registerKeyboardShortcut } from './keyboardShortcutRegistry.js' import { allowedSlots } from '../renderer/slots.js' -import { logger } from '../../utils/logger.js' +import { logger } from '../../services/logger.js' const asArray = (value) => Array.isArray(value) ? value : [value] diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index d228a2b0..41898b4b 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -1,7 +1,7 @@ // src/core/renderers/mapButtons.js import { MapButton } from '../components/MapButton/MapButton.jsx' import { allowedSlots } from './slots.js' -import { logger } from '../../utils/logger.js' +import { logger } from '../../services/logger.js' function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { const { breakpoint, mode } = appState diff --git a/src/App/store/ServiceProvider.jsx b/src/App/store/ServiceProvider.jsx index 254d2367..2fdf6f87 100755 --- a/src/App/store/ServiceProvider.jsx +++ b/src/App/store/ServiceProvider.jsx @@ -5,6 +5,7 @@ import { createAnnouncer } from '../../services/announcer.js' import { reverseGeocode } from '../../services/reverseGeocode.js' import { useConfig } from '../store/configContext.js' import { closeApp } from '../../services/closeApp.js' +import { logger } from '../../services/logger.js' export const ServiceContext = createContext(null) @@ -19,7 +20,8 @@ export const ServiceProvider = ({ eventBus, children }) => { events: EVENTS, eventBus, mapStatusRef, - closeApp: () => closeApp(id, handleExitClick, eventBus) + closeApp: () => closeApp(id, handleExitClick, eventBus), + logger }), [announce]) return ( diff --git a/src/App/store/appDispatchMiddleware.js b/src/App/store/appDispatchMiddleware.js index fcfbb718..2c88ab59 100644 --- a/src/App/store/appDispatchMiddleware.js +++ b/src/App/store/appDispatchMiddleware.js @@ -3,7 +3,7 @@ import { EVENTS as events } from '../../config/events.js' import { defaultPanelConfig, defaultButtonConfig, defaultControlConfig } from '../../config/appConfig.js' import { deepMerge } from '../../utils/deepMerge.js' import { allowedSlots } from '../renderer/slots.js' -import { logger } from '../../utils/logger.js' +import { logger } from '../../services/logger.js' const BREAKPOINTS = ['mobile', 'tablet', 'desktop'] diff --git a/src/utils/logger.js b/src/services/logger.js similarity index 100% rename from src/utils/logger.js rename to src/services/logger.js diff --git a/src/utils/logger.test.js b/src/services/logger.test.js similarity index 100% rename from src/utils/logger.test.js rename to src/services/logger.test.js From 9cebf0d494a47aa3973ffda966c7f74033aed2b1 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 21:39:22 +0000 Subject: [PATCH 07/14] Lint fixes --- .../beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index ad025e36..dd5bfc8e 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -111,7 +111,7 @@ export default class MaplibreLayerAdapter { }) const sourceIsShared = allDatasets.some(d => d.id !== dataset.id && getSourceId(d) === sourceId) - + if (!sourceIsShared && this._map.getSource(sourceId)) { this._map.removeSource(sourceId) } From a2de966ec6a36a8324e93d0aa4e8f7fe9bcbb680 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 21:40:32 +0000 Subject: [PATCH 08/14] Demo opacity amend --- demo/js/draw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/js/draw.js b/demo/js/draw.js index 2188d549..a4a4c6c8 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -79,7 +79,7 @@ const datasetsPlugin = createDatasetsPlugin({ fillPattern: 'diagonal-cross-hatch', fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, fillPatternBackgroundColor: 'transparent', - opacity: 0.2 + // opacity: 0.2 } }] }) From fc3bc4751e6ca18fb88c7569496f9f47ca985cc6 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 22:08:14 +0000 Subject: [PATCH 09/14] styleRules renamed to sublayers --- demo/js/index.js | 4 +- .../src/adapters/maplibre/layerBuilders.js | 20 ++--- .../src/adapters/maplibre/layerIds.js | 14 ++-- .../adapters/maplibre/maplibreLayerAdapter.js | 54 ++++++------- .../src/adapters/maplibre/patternRegistry.js | 10 +-- plugins/beta/datasets/src/api/hideRule.js | 4 - plugins/beta/datasets/src/api/hideSublayer.js | 4 + plugins/beta/datasets/src/api/setRuleStyle.js | 16 ---- .../beta/datasets/src/api/setSublayerStyle.js | 16 ++++ plugins/beta/datasets/src/api/showRule.js | 4 - plugins/beta/datasets/src/api/showSublayer.js | 4 + plugins/beta/datasets/src/manifest.js | 14 ++-- plugins/beta/datasets/src/panels/Key.jsx | 18 ++--- plugins/beta/datasets/src/panels/Layers.jsx | 58 +++++++------- plugins/beta/datasets/src/reducer.js | 42 +++++----- plugins/beta/datasets/src/utils/mergeRule.js | 78 ------------------- .../beta/datasets/src/utils/mergeSublayer.js | 78 +++++++++++++++++++ 17 files changed, 219 insertions(+), 219 deletions(-) delete mode 100644 plugins/beta/datasets/src/api/hideRule.js create mode 100644 plugins/beta/datasets/src/api/hideSublayer.js delete mode 100644 plugins/beta/datasets/src/api/setRuleStyle.js create mode 100644 plugins/beta/datasets/src/api/setSublayerStyle.js delete mode 100644 plugins/beta/datasets/src/api/showRule.js create mode 100644 plugins/beta/datasets/src/api/showSublayer.js delete mode 100644 plugins/beta/datasets/src/utils/mergeRule.js create mode 100644 plugins/beta/datasets/src/utils/mergeSublayer.js diff --git a/demo/js/index.js b/demo/js/index.js index 8e330bcb..3530848d 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -127,7 +127,7 @@ const datasetsPlugin = createDatasetsPlugin({ // fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, // fillPatternBackgroundColor: 'transparent' // }, - featureStyleRules: [{ + sublayers: [{ id: '130', label: 'Permanent grassland', filter: ['==', ['get', 'dominant_land_cover'], '130'], @@ -273,7 +273,7 @@ interactiveMap.on('map:ready', function (e) { interactiveMap.on('datasets:ready', function () { // setTimeout(() => datasetsPlugin.hideFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 2000) // setTimeout(() => datasetsPlugin.showFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 4000) - // setTimeout(() => datasetsPlugin.setRuleStyle({ datasetId: 'field-parcels', ruleId: '130', style: { stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } } }), 2000) + // setTimeout(() => datasetsPlugin.setSublayerStyle({ datasetId: 'field-parcels', sublayerId: '130', style: { stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } } }), 2000) }) // Ref to the selected features diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js index d06b167b..c1870a74 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +++ b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js @@ -1,7 +1,7 @@ import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' import { hasPattern, getPatternImageId } from '../../styles/patterns.js' -import { mergeRule } from '../../utils/mergeRule.js' -import { getSourceId, getLayerIds, getRuleLayerIds, isDynamicSource, MAX_TILE_ZOOM } from './layerIds.js' +import { mergeSublayer } from '../../utils/mergeSublayer.js' +import { getSourceId, getLayerIds, getSublayerLayerIds, isDynamicSource, MAX_TILE_ZOOM } from './layerIds.js' // ─── Source ─────────────────────────────────────────────────────────────────── @@ -74,12 +74,12 @@ export const addStrokeLayer = (map, config, layerId, sourceId, sourceLayer, visi // ─── Dataset layers ─────────────────────────────────────────────────────────── -export const addRuleLayers = (map, dataset, rule, sourceId, sourceLayer, mapStyleId) => { - const merged = mergeRule(dataset, rule) - const { fillLayerId, strokeLayerId } = getRuleLayerIds(dataset.id, rule.id) +export const addSublayerLayers = (map, dataset, sublayer, sourceId, sourceLayer, mapStyleId) => { + const merged = mergeSublayer(dataset, sublayer) + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayer.id) const parentHidden = dataset.visibility === 'hidden' - const ruleHidden = dataset.ruleVisibility?.[rule.id] === 'hidden' - const visibility = (parentHidden || ruleHidden) ? 'none' : 'visible' + const sublayerHidden = dataset.sublayerVisibility?.[sublayer.id] === 'hidden' + const visibility = (parentHidden || sublayerHidden) ? 'none' : 'visible' addFillLayer(map, merged, fillLayerId, sourceId, sourceLayer, visibility, mapStyleId) addStrokeLayer(map, merged, strokeLayerId, sourceId, sourceLayer, visibility, mapStyleId) } @@ -98,9 +98,9 @@ export const addDatasetLayers = (map, dataset, mapStyleId) => { const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined - if (dataset.featureStyleRules?.length) { - dataset.featureStyleRules.forEach(rule => { - addRuleLayers(map, dataset, rule, sourceId, sourceLayer, mapStyleId) + if (dataset.sublayers?.length) { + dataset.sublayers.forEach(sublayer => { + addSublayerLayers(map, dataset, sublayer, sourceId, sourceLayer, mapStyleId) }) return sourceId } diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerIds.js b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js index f66eacb4..433ce5cb 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +++ b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js @@ -52,16 +52,16 @@ export const getLayerIds = (dataset) => { return { fillLayerId, strokeLayerId } } -export const getRuleLayerIds = (datasetId, ruleId) => ({ - fillLayerId: `${datasetId}-${ruleId}`, - strokeLayerId: `${datasetId}-${ruleId}-stroke` +export const getSublayerLayerIds = (datasetId, sublayerId) => ({ + fillLayerId: `${datasetId}-${sublayerId}`, + strokeLayerId: `${datasetId}-${sublayerId}-stroke` }) export const getAllLayerIds = (dataset) => { - if (dataset.featureStyleRules?.length) { - return dataset.featureStyleRules.flatMap(rule => { - const { fillLayerId: ruleFill, strokeLayerId: ruleStroke } = getRuleLayerIds(dataset.id, rule.id) - return [ruleStroke, ruleFill] + if (dataset.sublayers?.length) { + return dataset.sublayers.flatMap(sublayer => { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayer.id) + return [strokeLayerId, fillLayerId] }) } const { fillLayerId, strokeLayerId } = getLayerIds(dataset) diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index dd5bfc8e..c0cbe60d 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -1,6 +1,6 @@ import { applyExclusionFilter } from '../../utils/filters.js' -import { getSourceId, getLayerIds, getRuleLayerIds, getAllLayerIds } from './layerIds.js' -import { addDatasetLayers, addRuleLayers } from './layerBuilders.js' +import { getSourceId, getLayerIds, getSublayerLayerIds, getAllLayerIds } from './layerIds.js' +import { addDatasetLayers, addSublayerLayers } from './layerBuilders.js' import { registerPatterns } from './patternRegistry.js' /** @@ -136,12 +136,12 @@ export default class MaplibreLayerAdapter { } /** - * Make a single featureStyleRule's layers visible. + * Make a single sublayer's layers visible. * @param {string} datasetId - * @param {string} ruleId + * @param {string} sublayerId */ - showRule (datasetId, ruleId) { - const { fillLayerId, strokeLayerId } = getRuleLayerIds(datasetId, ruleId) + showSublayer (datasetId, sublayerId) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId) if (this._map.getLayer(fillLayerId)) { this._map.setLayoutProperty(fillLayerId, 'visibility', 'visible') } @@ -151,12 +151,12 @@ export default class MaplibreLayerAdapter { } /** - * Hide a single featureStyleRule's layers. + * Hide a single sublayer's layers. * @param {string} datasetId - * @param {string} ruleId + * @param {string} sublayerId */ - hideRule (datasetId, ruleId) { - const { fillLayerId, strokeLayerId } = getRuleLayerIds(datasetId, ruleId) + hideSublayer (datasetId, sublayerId) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId) if (this._map.getLayer(fillLayerId)) { this._map.setLayoutProperty(fillLayerId, 'visibility', 'none') } @@ -204,28 +204,28 @@ export default class MaplibreLayerAdapter { } /** - * Update a single featureStyleRule's style and re-render its layers. - * @param {Object} dataset - Updated dataset (rule style changes already merged in) - * @param {string} ruleId + * Update a single sublayer's style and re-render its layers. + * @param {Object} dataset - Updated dataset (sublayer style changes already merged in) + * @param {string} sublayerId * @param {string} mapStyleId * @returns {Promise} */ - async setRuleStyle (dataset, ruleId, mapStyleId) { - const { fillLayerId, strokeLayerId } = getRuleLayerIds(dataset.id, ruleId) + async setSublayerStyle (dataset, sublayerId, mapStyleId) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayerId) if (this._map.getLayer(fillLayerId)) { this._map.removeLayer(fillLayerId) } if (this._map.getLayer(strokeLayerId)) { this._map.removeLayer(strokeLayerId) } - const rule = dataset.featureStyleRules?.find(r => r.id === ruleId) - if (!rule) { + const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) + if (!sublayer) { return } await registerPatterns(this._map, [dataset], mapStyleId) const sourceId = this._datasetSourceMap.get(dataset.id) const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined - addRuleLayers(this._map, dataset, rule, sourceId, sourceLayer, mapStyleId) + addSublayerLayers(this._map, dataset, sublayer, sourceId, sourceLayer, mapStyleId) } /** @@ -257,7 +257,7 @@ export default class MaplibreLayerAdapter { return } // Covers base fill layer (datasetId) and all suffixed layers - // (-stroke, -${ruleId}, -${ruleId}-stroke) without needing the dataset object. + // (-stroke, -${sublayerId}, -${sublayerId}-stroke) without needing the dataset object. style.layers .filter(layer => layer.id === datasetId || @@ -267,14 +267,14 @@ export default class MaplibreLayerAdapter { } _applyFeatureFilter (dataset, idProperty, excludeIds) { - if (dataset.featureStyleRules?.length) { - dataset.featureStyleRules.forEach(rule => { - const { fillLayerId: ruleFillId, strokeLayerId: ruleStrokeId } = getRuleLayerIds(dataset.id, rule.id) - const ruleFilter = dataset.filter && rule.filter - ? ['all', dataset.filter, rule.filter] - : (rule.filter || dataset.filter || null) - applyExclusionFilter(this._map, ruleFillId, ruleFilter, idProperty, excludeIds) - applyExclusionFilter(this._map, ruleStrokeId, ruleFilter, idProperty, excludeIds) + if (dataset.sublayers?.length) { + dataset.sublayers.forEach(sublayer => { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayer.id) + const sublayerFilter = dataset.filter && sublayer.filter + ? ['all', dataset.filter, sublayer.filter] + : (sublayer.filter || dataset.filter || null) + applyExclusionFilter(this._map, fillLayerId, sublayerFilter, idProperty, excludeIds) + applyExclusionFilter(this._map, strokeLayerId, sublayerFilter, idProperty, excludeIds) }) return } diff --git a/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js b/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js index 0d3a811f..07c928c0 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +++ b/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js @@ -1,18 +1,18 @@ import { hasPattern, getPatternImageId, rasterisePattern } from '../../styles/patterns.js' -import { mergeRule } from '../../utils/mergeRule.js' +import { mergeSublayer } from '../../utils/mergeSublayer.js' /** * Collect all style configs that require a pattern image: top-level datasets - * and any featureStyleRules whose merged style has a pattern. + * and any sublayers whose merged style has a pattern. * @param {Object[]} datasets * @returns {Object[]} */ const getPatternConfigs = (datasets) => datasets.flatMap(dataset => { const configs = hasPattern(dataset) ? [dataset] : [] - if (dataset.featureStyleRules?.length) { - dataset.featureStyleRules.forEach(rule => { - const merged = mergeRule(dataset, rule) + if (dataset.sublayers?.length) { + dataset.sublayers.forEach(sublayer => { + const merged = mergeSublayer(dataset, sublayer) if (hasPattern(merged)) { configs.push(merged) } diff --git a/plugins/beta/datasets/src/api/hideRule.js b/plugins/beta/datasets/src/api/hideRule.js deleted file mode 100644 index 883a45f3..00000000 --- a/plugins/beta/datasets/src/api/hideRule.js +++ /dev/null @@ -1,4 +0,0 @@ -export const hideRule = ({ pluginState }, datasetId, ruleId) => { - pluginState.layerAdapter?.hideRule(datasetId, ruleId) - pluginState.dispatch({ type: 'SET_RULE_VISIBILITY', payload: { datasetId, ruleId, visibility: 'hidden' } }) -} diff --git a/plugins/beta/datasets/src/api/hideSublayer.js b/plugins/beta/datasets/src/api/hideSublayer.js new file mode 100644 index 00000000..a0837efa --- /dev/null +++ b/plugins/beta/datasets/src/api/hideSublayer.js @@ -0,0 +1,4 @@ +export const hideSublayer = ({ pluginState }, datasetId, sublayerId) => { + pluginState.layerAdapter?.hideSublayer(datasetId, sublayerId) + pluginState.dispatch({ type: 'SET_SUBLAYER_VISIBILITY', payload: { datasetId, sublayerId, visibility: 'hidden' } }) +} diff --git a/plugins/beta/datasets/src/api/setRuleStyle.js b/plugins/beta/datasets/src/api/setRuleStyle.js deleted file mode 100644 index 1094ef79..00000000 --- a/plugins/beta/datasets/src/api/setRuleStyle.js +++ /dev/null @@ -1,16 +0,0 @@ -export const setRuleStyle = ({ pluginState, mapState }, { datasetId, ruleId, style }) => { - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { - return - } - - pluginState.dispatch({ type: 'SET_RULE_STYLE', payload: { datasetId, ruleId, styleChanges: style } }) - - const updatedDataset = { - ...dataset, - featureStyleRules: dataset.featureStyleRules?.map(rule => - rule.id === ruleId ? { ...rule, style: { ...rule.style, ...style } } : rule - ) - } - pluginState.layerAdapter?.setRuleStyle(updatedDataset, ruleId, mapState.mapStyle.id) -} diff --git a/plugins/beta/datasets/src/api/setSublayerStyle.js b/plugins/beta/datasets/src/api/setSublayerStyle.js new file mode 100644 index 00000000..9603aa81 --- /dev/null +++ b/plugins/beta/datasets/src/api/setSublayerStyle.js @@ -0,0 +1,16 @@ +export const setSublayerStyle = ({ pluginState, mapState }, { datasetId, sublayerId, style }) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + + pluginState.dispatch({ type: 'SET_SUBLAYER_STYLE', payload: { datasetId, sublayerId, styleChanges: style } }) + + const updatedDataset = { + ...dataset, + sublayers: dataset.sublayers?.map(sublayer => + sublayer.id === sublayerId ? { ...sublayer, style: { ...sublayer.style, ...style } } : sublayer + ) + } + pluginState.layerAdapter?.setSublayerStyle(updatedDataset, sublayerId, mapState.mapStyle.id) +} diff --git a/plugins/beta/datasets/src/api/showRule.js b/plugins/beta/datasets/src/api/showRule.js deleted file mode 100644 index 4230f2ac..00000000 --- a/plugins/beta/datasets/src/api/showRule.js +++ /dev/null @@ -1,4 +0,0 @@ -export const showRule = ({ pluginState }, datasetId, ruleId) => { - pluginState.layerAdapter?.showRule(datasetId, ruleId) - pluginState.dispatch({ type: 'SET_RULE_VISIBILITY', payload: { datasetId, ruleId, visibility: 'visible' } }) -} diff --git a/plugins/beta/datasets/src/api/showSublayer.js b/plugins/beta/datasets/src/api/showSublayer.js new file mode 100644 index 00000000..1680c8ab --- /dev/null +++ b/plugins/beta/datasets/src/api/showSublayer.js @@ -0,0 +1,4 @@ +export const showSublayer = ({ pluginState }, datasetId, sublayerId) => { + pluginState.layerAdapter?.showSublayer(datasetId, sublayerId) + pluginState.dispatch({ type: 'SET_SUBLAYER_VISIBILITY', payload: { datasetId, sublayerId, visibility: 'visible' } }) +} diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index 11e57aac..451ec166 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -9,10 +9,10 @@ import { addDataset } from './api/addDataset.js' import { removeDataset } from './api/removeDataset.js' import { showFeatures } from './api/showFeatures.js' import { hideFeatures } from './api/hideFeatures.js' -import { showRule } from './api/showRule.js' -import { hideRule } from './api/hideRule.js' +import { showSublayer } from './api/showSublayer.js' +import { hideSublayer } from './api/hideSublayer.js' import { setStyle } from './api/setStyle.js' -import { setRuleStyle } from './api/setRuleStyle.js' +import { setSublayerStyle } from './api/setSublayerStyle.js' import { setData } from './api/setData.js' export const manifest = { @@ -69,7 +69,7 @@ export const manifest = { panelId: 'datasetsLayers', iconId: 'layers', excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l => - l.toggleVisibility || l.featureStyleRules?.some(r => r.toggleVisibility) + l.toggleVisibility || l.sublayers?.some(r => r.toggleVisibility) ), mobile: { slot: 'top-left', @@ -118,10 +118,10 @@ export const manifest = { removeDataset, showFeatures, hideFeatures, - showRule, - hideRule, + showSublayer, + hideSublayer, setStyle, - setRuleStyle, + setSublayerStyle, setData } } diff --git a/plugins/beta/datasets/src/panels/Key.jsx b/plugins/beta/datasets/src/panels/Key.jsx index bfcc805f..0fa6ca98 100755 --- a/plugins/beta/datasets/src/panels/Key.jsx +++ b/plugins/beta/datasets/src/panels/Key.jsx @@ -1,7 +1,7 @@ import React from 'react' import { getValueForStyle } from '../../../../../src/utils/getValueForStyle' import { hasPattern, getKeyPatternPaths } from '../styles/patterns.js' -import { mergeRule } from '../utils/mergeRule.js' +import { mergeSublayer } from '../utils/mergeSublayer.js' const SVG_SIZE = 20 const SVG_CENTER = SVG_SIZE / 2 @@ -11,8 +11,8 @@ const buildKeyGroups = (datasets) => { const seenGroups = new Set() const items = [] datasets.forEach(dataset => { - if (dataset.featureStyleRules?.length) { - items.push({ type: 'rules', dataset }) + if (dataset.sublayers?.length) { + items.push({ type: 'sublayers', dataset }) return } if (dataset.groupLabel) { @@ -23,7 +23,7 @@ const buildKeyGroups = (datasets) => { items.push({ type: 'group', groupLabel: dataset.groupLabel, - datasets: datasets.filter(d => !d.featureStyleRules?.length && d.groupLabel === dataset.groupLabel) + datasets: datasets.filter(d => !d.sublayers?.length && d.groupLabel === dataset.groupLabel) }) return } @@ -105,20 +105,20 @@ export const Key = ({ mapState, pluginState }) => { .filter(dataset => dataset.showInKey && dataset.visibility !== 'hidden') const keyGroups = buildKeyGroups(visibleDatasets) - const hasGroups = keyGroups.some(item => item.type === 'rules' || item.type === 'group') + const hasGroups = keyGroups.some(item => item.type === 'sublayers' || item.type === 'group') const containerClass = `im-c-datasets-key${hasGroups ? ' im-c-datasets-key--has-groups' : ''}` return (
{keyGroups.map(item => { - if (item.type === 'rules') { + if (item.type === 'sublayers') { const headingId = `key-heading-${item.dataset.id}` return (

{item.dataset.label}

- {item.dataset.featureStyleRules - .filter(rule => item.dataset.ruleVisibility?.[rule.id] !== 'hidden') - .map(rule => renderEntry(`${item.dataset.id}-${rule.id}`, mergeRule(item.dataset, rule)))} + {item.dataset.sublayers + .filter(sublayer => item.dataset.sublayerVisibility?.[sublayer.id] !== 'hidden') + .map(sublayer => renderEntry(`${item.dataset.id}-${sublayer.id}`, mergeSublayer(item.dataset, sublayer)))}
) } diff --git a/plugins/beta/datasets/src/panels/Layers.jsx b/plugins/beta/datasets/src/panels/Layers.jsx index b68065d2..f12fa538 100755 --- a/plugins/beta/datasets/src/panels/Layers.jsx +++ b/plugins/beta/datasets/src/panels/Layers.jsx @@ -1,16 +1,16 @@ import React from 'react' import { showDataset } from '../api/showDataset' import { hideDataset } from '../api/hideDataset' -import { showRule } from '../api/showRule' -import { hideRule } from '../api/hideRule' +import { showSublayer } from '../api/showSublayer' +import { hideSublayer } from '../api/hideSublayer' const CHECKBOX_LABEL_CLASS = 'im-c-datasets-layers__item-label govuk-label govuk-checkboxes__label' -const hasToggleableRules = (dataset) => dataset.featureStyleRules?.some(rule => rule.toggleVisibility) +const hasToggleableSublayers = (dataset) => dataset.sublayers?.some(sublayer => sublayer.toggleVisibility) /** * Collapse the filtered dataset list into ordered render items: - * { type: 'rules', dataset } — dataset with featureStyleRules (takes precedence) + * { type: 'sublayers', dataset } — dataset with sublayers (takes precedence) * { type: 'group', groupLabel, datasets } — datasets sharing a groupLabel * { type: 'flat', dataset } — standalone dataset */ @@ -18,8 +18,8 @@ const buildRenderItems = (datasets) => { const seenGroups = new Set() const items = [] datasets.forEach(dataset => { - if (hasToggleableRules(dataset)) { - items.push({ type: 'rules', dataset }) + if (hasToggleableSublayers(dataset)) { + items.push({ type: 'sublayers', dataset }) return } if (dataset.groupLabel) { @@ -30,7 +30,7 @@ const buildRenderItems = (datasets) => { items.push({ type: 'group', groupLabel: dataset.groupLabel, - datasets: datasets.filter(d => !hasToggleableRules(d) && d.groupLabel === dataset.groupLabel) + datasets: datasets.filter(d => !hasToggleableSublayers(d) && d.groupLabel === dataset.groupLabel) }) return } @@ -49,14 +49,14 @@ export const Layers = ({ pluginState }) => { } } - const handleRuleChange = (e) => { + const handleSublayerChange = (e) => { const { checked } = e.target const datasetId = e.target.dataset.datasetId - const ruleId = e.target.dataset.ruleId + const sublayerId = e.target.dataset.sublayerId if (checked) { - showRule({ pluginState }, datasetId, ruleId) + showSublayer({ pluginState }, datasetId, sublayerId) } else { - hideRule({ pluginState }, datasetId, ruleId) + hideSublayer({ pluginState }, datasetId, sublayerId) } } @@ -83,45 +83,45 @@ export const Layers = ({ pluginState }) => { } const visibleDatasets = (pluginState.datasets || []) - .filter(dataset => dataset.toggleVisibility || hasToggleableRules(dataset)) + .filter(dataset => dataset.toggleVisibility || hasToggleableSublayers(dataset)) const renderItems = buildRenderItems(visibleDatasets) - const hasGroups = renderItems.some(item => item.type === 'rules' || item.type === 'group') + const hasGroups = renderItems.some(item => item.type === 'sublayers' || item.type === 'group') const containerClass = `im-c-datasets-layers${hasGroups ? ' im-c-datasets-layers--has-groups' : ''}` return (
{renderItems.map(item => { - if (item.type === 'rules') { + if (item.type === 'sublayers') { const { dataset } = item - const anyRuleChecked = dataset.featureStyleRules - .filter(rule => rule.toggleVisibility) - .some(rule => dataset.ruleVisibility?.[rule.id] !== 'hidden') - const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anyRuleChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` + const anySublayerChecked = dataset.sublayers + .filter(sublayer => sublayer.toggleVisibility) + .some(sublayer => dataset.sublayerVisibility?.[sublayer.id] !== 'hidden') + const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anySublayerChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` return (
{dataset.label} - {dataset.featureStyleRules - .filter(rule => rule.toggleVisibility) - .map(rule => { - const ruleVisible = dataset.ruleVisibility?.[rule.id] !== 'hidden' - const inputId = `${dataset.id}-${rule.id}` - const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${ruleVisible ? ' im-c-datasets-layers__item--checked' : ''}` + {dataset.sublayers + .filter(sublayer => sublayer.toggleVisibility) + .map(sublayer => { + const sublayerVisible = dataset.sublayerVisibility?.[sublayer.id] !== 'hidden' + const inputId = `${dataset.id}-${sublayer.id}` + const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${sublayerVisible ? ' im-c-datasets-layers__item--checked' : ''}` return ( -
+
diff --git a/plugins/beta/datasets/src/reducer.js b/plugins/beta/datasets/src/reducer.js index e71ee74f..dfda6eeb 100755 --- a/plugins/beta/datasets/src/reducer.js +++ b/plugins/beta/datasets/src/reducer.js @@ -6,22 +6,22 @@ const initialState = { layerAdapter: null } -const initRuleVisibility = (dataset) => { - if (!dataset.featureStyleRules?.length) { +const initSublayerVisibility = (dataset) => { + if (!dataset.sublayers?.length) { return dataset } - const ruleVisibility = {} - dataset.featureStyleRules.forEach(rule => { - ruleVisibility[rule.id] = 'visible' + const sublayerVisibility = {} + dataset.sublayers.forEach(sublayer => { + sublayerVisibility[sublayer.id] = 'visible' }) - return { ...dataset, ruleVisibility } + return { ...dataset, sublayerVisibility } } const setDatasets = (state, payload) => { const { datasets, datasetDefaults } = payload return { ...state, - datasets: datasets.map(dataset => initRuleVisibility(applyDatasetDefaults(dataset, datasetDefaults))) + datasets: datasets.map(dataset => initSublayerVisibility(applyDatasetDefaults(dataset, datasetDefaults))) } } @@ -31,7 +31,7 @@ const addDataset = (state, payload) => { ...state, datasets: [ ...(state.datasets || []), - initRuleVisibility(applyDatasetDefaults(dataset, datasetDefaults)) + initSublayerVisibility(applyDatasetDefaults(dataset, datasetDefaults)) ] } } @@ -93,8 +93,8 @@ const showFeatures = (state, payload) => { } } -const setRuleVisibility = (state, payload) => { - const { datasetId, ruleId, visibility } = payload +const setSublayerVisibility = (state, payload) => { + const { datasetId, sublayerId, visibility } = payload return { ...state, datasets: state.datasets?.map(dataset => { @@ -103,9 +103,9 @@ const setRuleVisibility = (state, payload) => { } return { ...dataset, - ruleVisibility: { - ...dataset.ruleVisibility, - [ruleId]: visibility + sublayerVisibility: { + ...dataset.sublayerVisibility, + [sublayerId]: visibility } } }) @@ -122,8 +122,8 @@ const setDatasetStyle = (state, payload) => { } } -const setRuleStyle = (state, payload) => { - const { datasetId, ruleId, styleChanges } = payload +const setSublayerStyle = (state, payload) => { + const { datasetId, sublayerId, styleChanges } = payload return { ...state, datasets: state.datasets?.map(dataset => { @@ -132,10 +132,10 @@ const setRuleStyle = (state, payload) => { } return { ...dataset, - featureStyleRules: dataset.featureStyleRules?.map(rule => - rule.id === ruleId - ? { ...rule, style: { ...rule.style, ...styleChanges } } - : rule + sublayers: dataset.sublayers?.map(sublayer => + sublayer.id === sublayerId + ? { ...sublayer, style: { ...sublayer.style, ...styleChanges } } + : sublayer ) } }) @@ -149,9 +149,9 @@ const actions = { ADD_DATASET: addDataset, REMOVE_DATASET: removeDataset, SET_DATASET_VISIBILITY: setDatasetVisibility, - SET_RULE_VISIBILITY: setRuleVisibility, + SET_SUBLAYER_VISIBILITY: setSublayerVisibility, SET_DATASET_STYLE: setDatasetStyle, - SET_RULE_STYLE: setRuleStyle, + SET_SUBLAYER_STYLE: setSublayerStyle, HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, SET_LAYER_ADAPTER: setLayerAdapter diff --git a/plugins/beta/datasets/src/utils/mergeRule.js b/plugins/beta/datasets/src/utils/mergeRule.js deleted file mode 100644 index 203ed77e..00000000 --- a/plugins/beta/datasets/src/utils/mergeRule.js +++ /dev/null @@ -1,78 +0,0 @@ -import { hasCustomVisualStyle } from '../defaults.js' - -const getFillProps = (dataset, ruleStyle) => { - if (ruleStyle.fillPattern || ruleStyle.fillPatternSvgContent) { - return { - fillPattern: ruleStyle.fillPattern, - fillPatternSvgContent: ruleStyle.fillPatternSvgContent, - fillPatternForegroundColor: ruleStyle.fillPatternForegroundColor ?? dataset.fillPatternForegroundColor, - fillPatternBackgroundColor: ruleStyle.fillPatternBackgroundColor ?? dataset.fillPatternBackgroundColor - } - } - if ('fill' in ruleStyle) { - // Rule explicitly sets a plain fill — do not inherit any parent pattern - return { fill: ruleStyle.fill } - } - return { - fill: dataset.fill, - fillPattern: dataset.fillPattern, - fillPatternSvgContent: dataset.fillPatternSvgContent, - fillPatternForegroundColor: dataset.fillPatternForegroundColor, - fillPatternBackgroundColor: dataset.fillPatternBackgroundColor - } -} - -const getCombinedFilter = (datasetFilter, ruleFilter) => { - if (datasetFilter && ruleFilter) { - return ['all', datasetFilter, ruleFilter] - } - return ruleFilter || datasetFilter || null -} - -const getSymbolDescription = (dataset, ruleStyle) => { - if ('symbolDescription' in ruleStyle) { - return ruleStyle.symbolDescription - } - if (hasCustomVisualStyle(ruleStyle)) { - return undefined - } - return dataset.symbolDescription -} - -/** - * Merge a featureStyleRule with its parent dataset, producing a flat style - * object suitable for layer creation and key symbol rendering. - * - * The rule's nested `style` object is flattened before merging. - * - * Fill precedence (highest to lowest): - * 1. Rule's own fillPattern - * 2. Rule's own fill (explicit, even if transparent — clears any parent pattern) - * 3. Parent's fillPattern - * 4. Parent's fill - * - * symbolDescription is only inherited from the parent when the rule has no - * custom visual styles of its own. If the rule overrides stroke/fill/pattern - * without setting symbolDescription explicitly, no description is shown. - */ -export const mergeRule = (dataset, rule) => { - const ruleStyle = rule.style || {} - const combinedFilter = getCombinedFilter(dataset.filter, rule.filter) - - return { - id: rule.id, - label: rule.label, - stroke: ruleStyle.stroke ?? dataset.stroke, - strokeWidth: ruleStyle.strokeWidth ?? dataset.strokeWidth, - strokeDashArray: ruleStyle.strokeDashArray ?? dataset.strokeDashArray, - opacity: ruleStyle.opacity ?? dataset.opacity, - keySymbolShape: ruleStyle.keySymbolShape ?? dataset.keySymbolShape, - symbolDescription: getSymbolDescription(dataset, ruleStyle), - showInKey: rule.showInKey ?? dataset.showInKey, - toggleVisibility: rule.toggleVisibility ?? false, - filter: combinedFilter, - minZoom: dataset.minZoom, - maxZoom: dataset.maxZoom, - ...getFillProps(dataset, ruleStyle) - } -} diff --git a/plugins/beta/datasets/src/utils/mergeSublayer.js b/plugins/beta/datasets/src/utils/mergeSublayer.js new file mode 100644 index 00000000..b6e68242 --- /dev/null +++ b/plugins/beta/datasets/src/utils/mergeSublayer.js @@ -0,0 +1,78 @@ +import { hasCustomVisualStyle } from '../defaults.js' + +const getFillProps = (dataset, sublayerStyle) => { + if (sublayerStyle.fillPattern || sublayerStyle.fillPatternSvgContent) { + return { + fillPattern: sublayerStyle.fillPattern, + fillPatternSvgContent: sublayerStyle.fillPatternSvgContent, + fillPatternForegroundColor: sublayerStyle.fillPatternForegroundColor ?? dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: sublayerStyle.fillPatternBackgroundColor ?? dataset.fillPatternBackgroundColor + } + } + if ('fill' in sublayerStyle) { + // Sublayer explicitly sets a plain fill — do not inherit any parent pattern + return { fill: sublayerStyle.fill } + } + return { + fill: dataset.fill, + fillPattern: dataset.fillPattern, + fillPatternSvgContent: dataset.fillPatternSvgContent, + fillPatternForegroundColor: dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: dataset.fillPatternBackgroundColor + } +} + +const getCombinedFilter = (datasetFilter, sublayerFilter) => { + if (datasetFilter && sublayerFilter) { + return ['all', datasetFilter, sublayerFilter] + } + return sublayerFilter || datasetFilter || null +} + +const getSymbolDescription = (dataset, sublayerStyle) => { + if ('symbolDescription' in sublayerStyle) { + return sublayerStyle.symbolDescription + } + if (hasCustomVisualStyle(sublayerStyle)) { + return undefined + } + return dataset.symbolDescription +} + +/** + * Merge a sublayer with its parent dataset, producing a flat style + * object suitable for layer creation and key symbol rendering. + * + * The sublayer's nested `style` object is flattened before merging. + * + * Fill precedence (highest to lowest): + * 1. Sublayer's own fillPattern + * 2. Sublayer's own fill (explicit, even if transparent — clears any parent pattern) + * 3. Parent's fillPattern + * 4. Parent's fill + * + * symbolDescription is only inherited from the parent when the sublayer has no + * custom visual styles of its own. If the sublayer overrides stroke/fill/pattern + * without setting symbolDescription explicitly, no description is shown. + */ +export const mergeSublayer = (dataset, sublayer) => { + const sublayerStyle = sublayer.style || {} + const combinedFilter = getCombinedFilter(dataset.filter, sublayer.filter) + + return { + id: sublayer.id, + label: sublayer.label, + stroke: sublayerStyle.stroke ?? dataset.stroke, + strokeWidth: sublayerStyle.strokeWidth ?? dataset.strokeWidth, + strokeDashArray: sublayerStyle.strokeDashArray ?? dataset.strokeDashArray, + opacity: sublayerStyle.opacity ?? dataset.opacity, + keySymbolShape: sublayerStyle.keySymbolShape ?? dataset.keySymbolShape, + symbolDescription: getSymbolDescription(dataset, sublayerStyle), + showInKey: sublayer.showInKey ?? dataset.showInKey, + toggleVisibility: sublayer.toggleVisibility ?? false, + filter: combinedFilter, + minZoom: dataset.minZoom, + maxZoom: dataset.maxZoom, + ...getFillProps(dataset, sublayerStyle) + } +} From 67aba35ba6065198357e65dccdbd4a36eeee1fbd Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 22:20:45 +0000 Subject: [PATCH 10/14] getStyle api methods added --- demo/js/index.js | 4 ++++ plugins/beta/datasets/src/api/getStyle.js | 7 +++++++ plugins/beta/datasets/src/api/getSublayerStyle.js | 11 +++++++++++ plugins/beta/datasets/src/manifest.js | 4 ++++ 4 files changed, 26 insertions(+) create mode 100644 plugins/beta/datasets/src/api/getStyle.js create mode 100644 plugins/beta/datasets/src/api/getSublayerStyle.js diff --git a/demo/js/index.js b/demo/js/index.js index 3530848d..4aa30eb4 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -274,6 +274,10 @@ interactiveMap.on('datasets:ready', function () { // setTimeout(() => datasetsPlugin.hideFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 2000) // setTimeout(() => datasetsPlugin.showFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 4000) // setTimeout(() => datasetsPlugin.setSublayerStyle({ datasetId: 'field-parcels', sublayerId: '130', style: { stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } } }), 2000) + setTimeout(() => { + console.log('getStyle (field-parcels):', datasetsPlugin.getStyle({ datasetId: 'field-parcels' })) + console.log('getSublayerStyle (field-parcels / 130):', datasetsPlugin.getSublayerStyle({ datasetId: 'field-parcels', sublayerId: '130' })) + }, 2000) }) // Ref to the selected features diff --git a/plugins/beta/datasets/src/api/getStyle.js b/plugins/beta/datasets/src/api/getStyle.js new file mode 100644 index 00000000..199c55f7 --- /dev/null +++ b/plugins/beta/datasets/src/api/getStyle.js @@ -0,0 +1,7 @@ +export const getStyle = ({ pluginState }, { datasetId }) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return null + } + return dataset.style ?? null +} diff --git a/plugins/beta/datasets/src/api/getSublayerStyle.js b/plugins/beta/datasets/src/api/getSublayerStyle.js new file mode 100644 index 00000000..f46e789e --- /dev/null +++ b/plugins/beta/datasets/src/api/getSublayerStyle.js @@ -0,0 +1,11 @@ +export const getSublayerStyle = ({ pluginState }, { datasetId, sublayerId }) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return null + } + const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) + if (!sublayer) { + return null + } + return sublayer.style ?? null +} diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index 451ec166..71fe81dc 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -13,6 +13,8 @@ import { showSublayer } from './api/showSublayer.js' import { hideSublayer } from './api/hideSublayer.js' import { setStyle } from './api/setStyle.js' import { setSublayerStyle } from './api/setSublayerStyle.js' +import { getStyle } from './api/getStyle.js' +import { getSublayerStyle } from './api/getSublayerStyle.js' import { setData } from './api/setData.js' export const manifest = { @@ -120,6 +122,8 @@ export const manifest = { hideFeatures, showSublayer, hideSublayer, + getStyle, + getSublayerStyle, setStyle, setSublayerStyle, setData From 56d92ea210f75c3864ce892ae8cf4b568fade8a3 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 26 Mar 2026 22:36:31 +0000 Subject: [PATCH 11/14] Dataset docs added --- docs/plugins.md | 2 +- docs/plugins/datasets.md | 558 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 docs/plugins/datasets.md diff --git a/docs/plugins.md b/docs/plugins.md index 0a570624..0a25f754 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -28,7 +28,7 @@ Location search plugin with autocomplete functionality. Include custom datasets The following plugins are in early development. APIs and features may change. -### Datasets +### [Datasets](./plugins/datasets.md) Add datasets to your map, configure the display, layer toggling and render a key of symbology. diff --git a/docs/plugins/datasets.md b/docs/plugins/datasets.md new file mode 100644 index 00000000..a935d0d6 --- /dev/null +++ b/docs/plugins/datasets.md @@ -0,0 +1,558 @@ +# Datasets Plugin + +The datasets plugin renders GeoJSON and vector tile datasets on the map, with support for sublayer style rules, layer visibility toggling, a key panel, and runtime style and data updates. + +## Usage + +```js +import createDatasetsPlugin from '@defra/interactive-map/plugins/beta/datasets' +import { maplibreLayerAdapter } from '@defra/interactive-map/plugins/beta/datasets/adapters/maplibre' + +const datasetsPlugin = createDatasetsPlugin({ + layerAdapter: maplibreLayerAdapter, + datasets: [ + { + id: 'my-parcels', + label: 'My parcels', + geojson: 'https://example.com/api/parcels', + minZoom: 10, + maxZoom: 24, + showInKey: true, + toggleVisibility: true, + style: { + stroke: '#d4351c', + strokeWidth: 2, + fill: 'transparent' + } + } + ] +}) + +const interactiveMap = new InteractiveMap({ + plugins: [datasetsPlugin] +}) +``` + +## Options + +Options are passed to the factory function when creating the plugin. + +--- + +### `layerAdapter` + +**Type:** `LayerAdapter` +**Required** + +The map provider adapter responsible for rendering datasets. Import `maplibreLayerAdapter` for MapLibre GL JS, or supply a custom adapter. + +```js +import { maplibreLayerAdapter } from '@defra/interactive-map/plugins/beta/datasets/adapters/maplibre' +``` + +--- + +### `datasets` + +**Type:** `Dataset[]` +**Required** + +Array of dataset configurations to render on the map. See [Dataset configuration](#dataset-configuration) below. + +--- + +### `includeModes` + +**Type:** `string[]` + +When set, the plugin only initialises when the app is in one of the specified modes. + +--- + +### `excludeModes` + +**Type:** `string[]` + +When set, the plugin does not initialise when the app is in one of the specified modes. + +--- + +## Dataset configuration + +Each entry in the `datasets` array describes one data source and how it should be rendered. + +--- + +### `id` + +**Type:** `string` +**Required** + +Unique identifier for the dataset. Used in all API method calls. + +--- + +### `label` + +**Type:** `string` + +Human-readable name shown in the Layers panel and Key panel. + +--- + +### `geojson` + +**Type:** `string | GeoJSON.FeatureCollection` + +GeoJSON source. Provide a URL string for remote data, or a GeoJSON object for inline data. Use alongside `transformRequest` for dynamic bbox-based fetching. + +--- + +### `tiles` + +**Type:** `string[]` + +Array of vector tile URL templates (e.g. `https://example.com/tiles/{z}/{x}/{y}`). When set, the dataset uses a vector tile source instead of GeoJSON. + +--- + +### `sourceLayer` + +**Type:** `string` + +The layer name within the vector tile source to render. Required when using `tiles`. + +--- + +### `transformRequest` + +**Type:** `Function` + +A function called with the current map bounds to build a dynamic fetch URL. Required for dynamic bbox-based GeoJSON fetching. + +```js +transformRequest: (bbox) => `https://example.com/api/items?bbox=${bbox.join(',')}` +``` + +--- + +### `idProperty` + +**Type:** `string` + +Property name used as the unique feature identifier. Required for dynamic fetching and feature deduplication. + +--- + +### `filter` + +**Type:** `FilterExpression` + +A MapLibre filter expression applied to the dataset's map layers. Features not matching the filter are not rendered. + +```js +filter: ['==', ['get', 'status'], 'active'] +``` + +--- + +### `minZoom` + +**Type:** `number` +**Default:** `6` + +Minimum zoom level at which the dataset is visible. + +--- + +### `maxZoom` + +**Type:** `number` +**Default:** `24` + +Maximum zoom level at which the dataset is visible. + +--- + +### `maxFeatures` + +**Type:** `number` + +Maximum number of features to hold in memory for dynamic sources. When exceeded, features far from the current viewport are evicted. + +--- + +### `visibility` + +**Type:** `'visible' | 'hidden'` +**Default:** `'visible'` + +Initial visibility of the dataset. + +--- + +### `showInKey` + +**Type:** `boolean` +**Default:** `false` + +When `true`, the dataset appears in the Key panel with its style symbol and label. + +--- + +### `toggleVisibility` + +**Type:** `boolean` +**Default:** `false` + +When `true`, the dataset appears in the Layers panel and can be toggled on and off by the user. + +--- + +### `groupLabel` + +**Type:** `string` + +Groups this dataset with others sharing the same `groupLabel` in the Layers panel, rendering them as a single collapsible group. + +--- + +### `keySymbolShape` + +**Type:** `'polygon' | 'line'` + +Overrides the shape used to render the key symbol for this dataset. Defaults to a polygon shape. + +--- + +### `style` + +**Type:** `Object` + +Visual style for the dataset. All style properties must be nested within this object. + +| Property | Type | Description | +|----------|------|-------------| +| `stroke` | `string \| Record` | Stroke (outline) colour. Accepts a plain colour string or a map-style-keyed object e.g. `{ outdoor: '#ff0000', dark: '#ffffff' }` | +| `strokeWidth` | `number` | Stroke width in pixels. **Default:** `2` | +| `strokeDashArray` | `number[]` | Dash pattern for the stroke e.g. `[4, 2]` | +| `fill` | `string \| Record` | Fill colour. Use `'transparent'` for no fill | +| `fillPattern` | `string` | Named fill pattern e.g. `'diagonal-cross-hatch'`, `'horizontal-hatch'`, `'dot'`, `'vertical-hatch'` | +| `fillPatternSvgContent` | `string` | Raw SVG content for a custom fill pattern | +| `fillPatternForegroundColor` | `string \| Record` | Foreground colour for the fill pattern | +| `fillPatternBackgroundColor` | `string \| Record` | Background colour for the fill pattern | +| `opacity` | `number` | Layer opacity from `0` to `1` | +| `symbolDescription` | `string \| Record` | Accessible description of the symbol shown in the key | +| `keySymbolShape` | `'polygon' \| 'line'` | Shape used for the key symbol | + +```js +style: { + stroke: { outdoor: '#d4351c', dark: '#ffffff' }, + strokeWidth: 2, + fill: 'rgba(212,53,28,0.1)', + symbolDescription: { outdoor: 'Red outline' } +} +``` + +--- + +### `sublayers` + +**Type:** `Sublayer[]` + +Array of sublayer rules that partition the dataset into visually distinct groups based on feature filters. Each sublayer is rendered as a separate pair of map layers. + +Sublayers inherit the parent dataset's style and only override what they specify. Fill precedence (highest to lowest): sublayer's own `fillPattern` → sublayer's own `fill` → parent's `fillPattern` → parent's `fill`. + +#### `Sublayer` properties + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | **Required.** Unique identifier within the dataset | +| `label` | `string` | Human-readable name shown in the Layers and Key panels | +| `filter` | `FilterExpression` | MapLibre filter expression to match features for this sublayer | +| `style` | `Object` | Style overrides for this sublayer. Accepts the same properties as the dataset `style` object | +| `showInKey` | `boolean` | Shows this sublayer in the Key panel. Inherits from dataset if not set | +| `toggleVisibility` | `boolean` | Shows this sublayer in the Layers panel. **Default:** `false` | + +```js +sublayers: [ + { + id: 'active', + label: 'Active parcels', + filter: ['==', ['get', 'status'], 'active'], + toggleVisibility: true, + style: { + stroke: '#00703c', + fill: 'rgba(0,112,60,0.1)', + symbolDescription: 'Green outline' + } + }, + { + id: 'inactive', + label: 'Inactive parcels', + filter: ['==', ['get', 'status'], 'inactive'], + toggleVisibility: true, + style: { + stroke: '#d4351c', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: '#d4351c' + } + } +] +``` + +--- + +## Methods + +Methods are called on the plugin instance after the `datasets:ready` event. + +--- + +### `addDataset(dataset)` + +Add a new dataset to the map at runtime. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `dataset` | `Dataset` | Dataset configuration object. Accepts the same properties as `datasets` array entries | + +```js +interactiveMap.on('datasets:ready', () => { + datasetsPlugin.addDataset({ + id: 'new-layer', + geojson: 'https://example.com/api/features', + minZoom: 10, + style: { stroke: '#0000ff' } + }) +}) +``` + +--- + +### `removeDataset(datasetId)` + +Remove a dataset from the map. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `datasetId` | `string` | ID of the dataset to remove | + +```js +datasetsPlugin.removeDataset('my-parcels') +``` + +--- + +### `showDataset(datasetId)` + +Make a hidden dataset visible. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `datasetId` | `string` | ID of the dataset | + +```js +datasetsPlugin.showDataset('my-parcels') +``` + +--- + +### `hideDataset(datasetId)` + +Hide a visible dataset. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `datasetId` | `string` | ID of the dataset | + +```js +datasetsPlugin.hideDataset('my-parcels') +``` + +--- + +### `showSublayer(datasetId, sublayerId)` + +Make a hidden sublayer visible. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `datasetId` | `string` | ID of the dataset | +| `sublayerId` | `string` | ID of the sublayer | + +```js +datasetsPlugin.showSublayer('my-parcels', 'active') +``` + +--- + +### `hideSublayer(datasetId, sublayerId)` + +Hide a visible sublayer. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `datasetId` | `string` | ID of the dataset | +| `sublayerId` | `string` | ID of the sublayer | + +```js +datasetsPlugin.hideSublayer('my-parcels', 'active') +``` + +--- + +### `showFeatures(options)` + +Show previously hidden features within a dataset. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.datasetId` | `string` | ID of the dataset | +| `options.featureIds` | `(string \| number)[]` | IDs of features to show | +| `options.idProperty` | `string` | Property name used as the feature identifier | + +```js +datasetsPlugin.showFeatures({ + datasetId: 'my-parcels', + featureIds: [123, 456], + idProperty: 'id' +}) +``` + +--- + +### `hideFeatures(options)` + +Hide specific features within a dataset without removing them from the source. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.datasetId` | `string` | ID of the dataset | +| `options.featureIds` | `(string \| number)[]` | IDs of features to hide | +| `options.idProperty` | `string` | Property name used as the feature identifier | + +```js +datasetsPlugin.hideFeatures({ + datasetId: 'my-parcels', + featureIds: [123, 456], + idProperty: 'id' +}) +``` + +--- + +### `setStyle(options)` + +Update the visual style of a dataset at runtime. Affects only properties that sublayers do not themselves override. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.datasetId` | `string` | ID of the dataset | +| `options.style` | `Object` | Style properties to apply. Accepts the same properties as `dataset.style` | + +```js +datasetsPlugin.setStyle({ + datasetId: 'my-parcels', + style: { + stroke: '#0000ff', + strokeWidth: 3 + } +}) +``` + +--- + +### `setSublayerStyle(options)` + +Update the visual style of a specific sublayer at runtime. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.datasetId` | `string` | ID of the dataset | +| `options.sublayerId` | `string` | ID of the sublayer | +| `options.style` | `Object` | Style properties to apply. Accepts the same properties as `sublayer.style` | + +```js +datasetsPlugin.setSublayerStyle({ + datasetId: 'my-parcels', + sublayerId: 'active', + style: { + stroke: '#00703c', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: '#00703c' + } +}) +``` + +--- + +### `getStyle(options)` + +Returns the current style object for a dataset, or `null` if the dataset is not found. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.datasetId` | `string` | ID of the dataset | + +```js +const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels' }) +console.log(style) // { stroke: '#d4351c', strokeWidth: 2, ... } +``` + +--- + +### `getSublayerStyle(options)` + +Returns the current style object for a sublayer, or `null` if the dataset or sublayer is not found. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.datasetId` | `string` | ID of the dataset | +| `options.sublayerId` | `string` | ID of the sublayer | + +```js +const style = datasetsPlugin.getSublayerStyle({ datasetId: 'my-parcels', sublayerId: 'active' }) +console.log(style) // { stroke: '#00703c', fill: 'rgba(0,112,60,0.1)' } +``` + +--- + +### `setData(options)` + +Replace the GeoJSON data for a dataset source. Has no effect on vector tile datasets — use `transformRequest` for those. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.datasetId` | `string` | ID of the dataset | +| `options.geojson` | `GeoJSON.FeatureCollection` | New GeoJSON data | + +```js +datasetsPlugin.setData({ + datasetId: 'my-parcels', + geojson: { type: 'FeatureCollection', features: [...] } +}) +``` + +--- + +## Events + +Subscribe to events using `interactiveMap.on()`. + +--- + +### `datasets:ready` + +Emitted once all datasets have been initialised and rendered on the map. + +**Payload:** None + +```js +interactiveMap.on('datasets:ready', () => { + console.log('Datasets are ready') + // Safe to call API methods from here + const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels' }) +}) +``` From 1b8d2dc487652c89ef7ceb4d282999ec8de14519 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 27 Mar 2026 11:30:19 +0000 Subject: [PATCH 12/14] setOpacity and docs updated --- demo/js/index.js | 9 +- docs/plugins/datasets.md | 157 ++++++++++++++++-- .../adapters/maplibre/maplibreLayerAdapter.js | 44 ++++- plugins/beta/datasets/src/api/getOpacity.js | 8 + .../datasets/src/api/getSublayerOpacity.js | 9 + plugins/beta/datasets/src/api/setOpacity.js | 18 ++ .../datasets/src/api/setSublayerOpacity.js | 9 + plugins/beta/datasets/src/api/showDataset.js | 10 ++ plugins/beta/datasets/src/manifest.js | 8 + plugins/beta/datasets/src/reducer.js | 41 +++++ 10 files changed, 290 insertions(+), 23 deletions(-) create mode 100644 plugins/beta/datasets/src/api/getOpacity.js create mode 100644 plugins/beta/datasets/src/api/getSublayerOpacity.js create mode 100644 plugins/beta/datasets/src/api/setOpacity.js create mode 100644 plugins/beta/datasets/src/api/setSublayerOpacity.js diff --git a/demo/js/index.js b/demo/js/index.js index 4aa30eb4..5f40c0fc 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -101,8 +101,7 @@ const datasetsPlugin = createDatasetsPlugin({ // ], // tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'], // sourceLayer: 'field_parcels_filtered', - featureLayer: '', - vectorTileLayer: '', + // featureLayer: '', // idProperty: 'id', // Enables dynamic fetching + deduplication // filter: ['get', ['propertyName', 'warning']], query: {}, @@ -130,7 +129,7 @@ const datasetsPlugin = createDatasetsPlugin({ sublayers: [{ id: '130', label: 'Permanent grassland', - filter: ['==', ['get', 'dominant_land_cover'], '130'], + filter: ['==', ['get', 'dominant_land_cover'], '130'], // 'dominant_land_cover = "130"' toggleVisibility: true, style: { stroke: { outdoor: '#82F584', dark: '#ffffff' }, @@ -274,10 +273,6 @@ interactiveMap.on('datasets:ready', function () { // setTimeout(() => datasetsPlugin.hideFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 2000) // setTimeout(() => datasetsPlugin.showFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 4000) // setTimeout(() => datasetsPlugin.setSublayerStyle({ datasetId: 'field-parcels', sublayerId: '130', style: { stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } } }), 2000) - setTimeout(() => { - console.log('getStyle (field-parcels):', datasetsPlugin.getStyle({ datasetId: 'field-parcels' })) - console.log('getSublayerStyle (field-parcels / 130):', datasetsPlugin.getSublayerStyle({ datasetId: 'field-parcels', sublayerId: '130' })) - }, 2000) }) // Ref to the selected features diff --git a/docs/plugins/datasets.md b/docs/plugins/datasets.md index a935d0d6..3d6c71d2 100644 --- a/docs/plugins/datasets.md +++ b/docs/plugins/datasets.md @@ -104,7 +104,7 @@ Human-readable name shown in the Layers panel and Key panel. **Type:** `string | GeoJSON.FeatureCollection` -GeoJSON source. Provide a URL string for remote data, or a GeoJSON object for inline data. Use alongside `transformRequest` for dynamic bbox-based fetching. +GeoJSON source. Provide a URL string for remote data, or a GeoJSON object for inline data. Use alongside `transformRequest` to add authentication or append bbox parameters to the request. --- @@ -128,10 +128,42 @@ The layer name within the vector tile source to render. Required when using `til **Type:** `Function` -A function called with the current map bounds to build a dynamic fetch URL. Required for dynamic bbox-based GeoJSON fetching. +A function called before each fetch to transform the request. Its primary purpose is to attach authentication credentials — API keys, OAuth tokens, or other headers. It also receives the current viewport context so you can append bbox or zoom parameters to the URL if your API supports spatial filtering. + +The plugin handles all dynamic fetching concerns (viewport tracking, debouncing, deduplication, caching, request cancellation) — `transformRequest` only needs to return the final URL and any headers. + +**Signature:** `transformRequest(url, { bbox, zoom, dataset })` + +| Argument | Type | Description | +|----------|------|-------------| +| `url` | `string` | The base URL from `geojson` | +| `bbox` | `number[]` | Current viewport bounds as `[west, south, east, north]` | +| `zoom` | `number` | Current map zoom level | +| `dataset` | `Object` | The full dataset configuration | + +Return either a plain URL string or an object `{ url, headers }`. The object form is needed when attaching auth headers. ```js -transformRequest: (bbox) => `https://example.com/api/items?bbox=${bbox.join(',')}` +// Auth headers only (no bbox filtering) +transformRequest: (url) => ({ + url, + headers: { Authorization: `Bearer ${getToken()}` } +}) + +// Append bbox to URL for server-side spatial filtering +transformRequest: (url, { bbox }) => { + const separator = url.includes('?') ? '&' : '?' + return { url: `${url}${separator}bbox=${bbox.join(',')}` } +} + +// Both — auth + bbox +transformRequest: (url, { bbox }) => { + const separator = url.includes('?') ? '&' : '?' + return { + url: `${url}${separator}bbox=${bbox.join(',')}`, + headers: { Authorization: `Bearer ${getToken()}` } + } +} ``` --- @@ -140,7 +172,7 @@ transformRequest: (bbox) => `https://example.com/api/items?bbox=${bbox.join(',') **Type:** `string` -Property name used as the unique feature identifier. Required for dynamic fetching and feature deduplication. +Property name used to uniquely identify features. Required alongside `transformRequest` to enable dynamic bbox-based fetching — the plugin uses it internally to deduplicate features across successive viewport fetches. --- @@ -177,8 +209,23 @@ Maximum zoom level at which the dataset is visible. ### `maxFeatures` **Type:** `number` +**Default:** none — omitting this option disables eviction entirely + +Only applies to dynamic sources (those using `transformRequest`). When set, the plugin tracks how many features are held in memory across all viewport fetches and evicts older features once the limit is exceeded. -Maximum number of features to hold in memory for dynamic sources. When exceeded, features far from the current viewport are evicted. +Eviction triggers at 120% of `maxFeatures` to avoid running on every fetch when hovering near the limit. Out-of-viewport features are evicted first, sorted by how recently they were visible. Features currently in the viewport are only evicted if out-of-viewport eviction alone is not sufficient. When features are evicted, the plugin resets its tracked fetch area so those regions will be re-fetched if the user pans back. + +**When to set it:** omit `maxFeatures` for small or bounded datasets where accumulation is not a concern. Set it when your dataset is large enough that features could accumulate significantly over a long session — for example a national-scale dataset at medium zoom, or any dataset where users are expected to pan extensively. + +```js +{ + id: 'my-parcels', + geojson: 'https://example.com/api/parcels', + transformRequest: transformDataRequest, + idProperty: 'id', + maxFeatures: 10000 +} +``` --- @@ -347,7 +394,7 @@ datasetsPlugin.removeDataset('my-parcels') ### `showDataset(datasetId)` -Make a hidden dataset visible. +Make a hidden dataset visible. If the dataset has sublayers, any that were individually hidden before the dataset was hidden will remain hidden — their individual visibility state is preserved. | Parameter | Type | Description | |-----------|------|-------------| @@ -361,7 +408,7 @@ datasetsPlugin.showDataset('my-parcels') ### `hideDataset(datasetId)` -Hide a visible dataset. +Hide a visible dataset and all its sublayers. Individual sublayer visibility state is preserved so it can be correctly restored when the dataset is shown again. | Parameter | Type | Description | |-----------|------|-------------| @@ -375,7 +422,7 @@ datasetsPlugin.hideDataset('my-parcels') ### `showSublayer(datasetId, sublayerId)` -Make a hidden sublayer visible. +Make a hidden sublayer visible. Has no effect if the parent dataset is currently hidden. | Parameter | Type | Description | |-----------|------|-------------| @@ -390,7 +437,7 @@ datasetsPlugin.showSublayer('my-parcels', 'active') ### `hideSublayer(datasetId, sublayerId)` -Hide a visible sublayer. +Hide a single sublayer without affecting the parent dataset or other sublayers. | Parameter | Type | Description | |-----------|------|-------------| @@ -411,13 +458,21 @@ Show previously hidden features within a dataset. |-----------|------|-------------| | `options.datasetId` | `string` | ID of the dataset | | `options.featureIds` | `(string \| number)[]` | IDs of features to show | -| `options.idProperty` | `string` | Property name used as the feature identifier | +| `options.idProperty` | `string \| null` | Property name to match features on. Pass `null` to match against the top-level `feature.id` instead | ```js +// Match by a feature property datasetsPlugin.showFeatures({ datasetId: 'my-parcels', featureIds: [123, 456], - idProperty: 'id' + idProperty: 'parcel_id' +}) + +// Match by feature.id (no property needed) +datasetsPlugin.showFeatures({ + datasetId: 'my-parcels', + featureIds: [123, 456], + idProperty: null }) ``` @@ -431,13 +486,21 @@ Hide specific features within a dataset without removing them from the source. |-----------|------|-------------| | `options.datasetId` | `string` | ID of the dataset | | `options.featureIds` | `(string \| number)[]` | IDs of features to hide | -| `options.idProperty` | `string` | Property name used as the feature identifier | +| `options.idProperty` | `string \| null` | Property name to match features on. Pass `null` to match against the top-level `feature.id` instead | ```js +// Match by a feature property +datasetsPlugin.hideFeatures({ + datasetId: 'my-parcels', + featureIds: [123, 456], + idProperty: 'parcel_id' +}) + +// Match by feature.id (no property needed) datasetsPlugin.hideFeatures({ datasetId: 'my-parcels', featureIds: [123, 456], - idProperty: 'id' + idProperty: null }) ``` @@ -519,6 +582,74 @@ console.log(style) // { stroke: '#00703c', fill: 'rgba(0,112,60,0.1)' } --- +### `setOpacity(opacity)` / `setOpacity(datasetId, opacity)` + +Set the opacity of all datasets or a single dataset. Safe to call on every tick from a slider — uses `setPaintProperty` internally rather than removing and re-adding layers. + +| Argument | Type | Description | +|----------|------|-------------| +| `opacity` | `number` | Opacity from `0` (transparent) to `1` (fully opaque). Omit `datasetId` to apply globally | +| `datasetId` | `string` | Optional. When provided, only that dataset is affected | + +```js +// Global — all datasets +datasetsPlugin.setOpacity(0.5) + +// Single dataset +datasetsPlugin.setOpacity('my-parcels', 0.5) +``` + +--- + +### `getOpacity()` / `getOpacity(datasetId)` + +Returns the current opacity for a dataset, or the first dataset's opacity when called without arguments. Returns `null` if the dataset is not found. + +| Argument | Type | Description | +|----------|------|-------------| +| `datasetId` | `string` | Optional. When omitted, returns the opacity of the first dataset | + +```js +// Read back after setting globally — useful for initialising a slider +const opacity = datasetsPlugin.getOpacity() + +// Single dataset +const opacity = datasetsPlugin.getOpacity('my-parcels') +``` + +--- + +### `setSublayerOpacity(datasetId, sublayerId, opacity)` + +Set the opacity of a single sublayer. Safe to call on every tick from a slider. + +| Argument | Type | Description | +|----------|------|-------------| +| `datasetId` | `string` | ID of the dataset | +| `sublayerId` | `string` | ID of the sublayer | +| `opacity` | `number` | Opacity from `0` to `1` | + +```js +datasetsPlugin.setSublayerOpacity('my-parcels', 'active', 0.5) +``` + +--- + +### `getSublayerOpacity(datasetId, sublayerId)` + +Returns the current opacity for a sublayer, or `null` if the dataset or sublayer is not found. + +| Argument | Type | Description | +|----------|------|-------------| +| `datasetId` | `string` | ID of the dataset | +| `sublayerId` | `string` | ID of the sublayer | + +```js +const opacity = datasetsPlugin.getSublayerOpacity('my-parcels', 'active') +``` + +--- + ### `setData(options)` Replace the GeoJSON data for a dataset source. Has no effect on vector tile datasets — use `transformRequest` for those. diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index c0cbe60d..bbf2c490 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -228,6 +228,35 @@ export default class MaplibreLayerAdapter { addSublayerLayers(this._map, dataset, sublayer, sourceId, sourceLayer, mapStyleId) } + /** + * Set opacity for all layers belonging to a dataset. + * Uses setPaintProperty directly — safe to call on every slider tick. + * @param {string} datasetId + * @param {number} opacity + */ + setOpacity (datasetId, opacity) { + const style = this._map.getStyle() + if (!style?.layers) { + return + } + style.layers + .filter(layer => layer.id === datasetId || layer.id.startsWith(`${datasetId}-`)) + .forEach(layer => this._setPaintOpacity(layer.id, opacity)) + } + + /** + * Set opacity for a single sublayer's fill and stroke layers. + * Uses setPaintProperty directly — safe to call on every slider tick. + * @param {string} datasetId + * @param {string} sublayerId + * @param {number} opacity + */ + setSublayerOpacity (datasetId, sublayerId, opacity) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId) + this._setPaintOpacity(fillLayerId, opacity) + this._setPaintOpacity(strokeLayerId, opacity) + } + /** * Update the GeoJSON data for a dataset's source. * @param {string} datasetId @@ -269,12 +298,12 @@ export default class MaplibreLayerAdapter { _applyFeatureFilter (dataset, idProperty, excludeIds) { if (dataset.sublayers?.length) { dataset.sublayers.forEach(sublayer => { - const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayer.id) + const { fillLayerId: subFillId, strokeLayerId: subStrokeId } = getSublayerLayerIds(dataset.id, sublayer.id) const sublayerFilter = dataset.filter && sublayer.filter ? ['all', dataset.filter, sublayer.filter] : (sublayer.filter || dataset.filter || null) - applyExclusionFilter(this._map, fillLayerId, sublayerFilter, idProperty, excludeIds) - applyExclusionFilter(this._map, strokeLayerId, sublayerFilter, idProperty, excludeIds) + applyExclusionFilter(this._map, subFillId, sublayerFilter, idProperty, excludeIds) + applyExclusionFilter(this._map, subStrokeId, sublayerFilter, idProperty, excludeIds) }) return } @@ -288,6 +317,15 @@ export default class MaplibreLayerAdapter { } } + _setPaintOpacity (layerId, opacity) { + const layer = this._map.getLayer(layerId) + if (!layer) { + return + } + const prop = layer.type === 'line' ? 'line-opacity' : 'fill-opacity' + this._map.setPaintProperty(layerId, prop, opacity) + } + _getLayersUsingSource (sourceId) { const style = this._map.getStyle() if (!style?.layers) { diff --git a/plugins/beta/datasets/src/api/getOpacity.js b/plugins/beta/datasets/src/api/getOpacity.js new file mode 100644 index 00000000..1a033ee2 --- /dev/null +++ b/plugins/beta/datasets/src/api/getOpacity.js @@ -0,0 +1,8 @@ +export const getOpacity = ({ pluginState }, datasetId) => { + if (!datasetId) { + return pluginState.datasets?.[0]?.opacity ?? null + } + + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + return dataset?.opacity ?? null +} diff --git a/plugins/beta/datasets/src/api/getSublayerOpacity.js b/plugins/beta/datasets/src/api/getSublayerOpacity.js new file mode 100644 index 00000000..d2c46dca --- /dev/null +++ b/plugins/beta/datasets/src/api/getSublayerOpacity.js @@ -0,0 +1,9 @@ +export const getSublayerOpacity = ({ pluginState }, datasetId, sublayerId) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return null + } + + const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) + return sublayer?.style?.opacity ?? null +} diff --git a/plugins/beta/datasets/src/api/setOpacity.js b/plugins/beta/datasets/src/api/setOpacity.js new file mode 100644 index 00000000..b41f94ec --- /dev/null +++ b/plugins/beta/datasets/src/api/setOpacity.js @@ -0,0 +1,18 @@ +export const setOpacity = ({ pluginState }, datasetIdOrOpacity, opacity) => { + if (typeof datasetIdOrOpacity === 'number') { + pluginState.dispatch({ type: 'SET_GLOBAL_OPACITY', payload: { opacity: datasetIdOrOpacity } }) + pluginState.datasets?.forEach(d => { + pluginState.layerAdapter?.setOpacity(d.id, datasetIdOrOpacity) + }) + return + } + + const datasetId = datasetIdOrOpacity + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + + pluginState.dispatch({ type: 'SET_OPACITY', payload: { datasetId, opacity } }) + pluginState.layerAdapter?.setOpacity(datasetId, opacity) +} diff --git a/plugins/beta/datasets/src/api/setSublayerOpacity.js b/plugins/beta/datasets/src/api/setSublayerOpacity.js new file mode 100644 index 00000000..44165c19 --- /dev/null +++ b/plugins/beta/datasets/src/api/setSublayerOpacity.js @@ -0,0 +1,9 @@ +export const setSublayerOpacity = ({ pluginState }, datasetId, sublayerId, opacity) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + + pluginState.dispatch({ type: 'SET_SUBLAYER_OPACITY', payload: { datasetId, sublayerId, opacity } }) + pluginState.layerAdapter?.setSublayerOpacity(datasetId, sublayerId, opacity) +} diff --git a/plugins/beta/datasets/src/api/showDataset.js b/plugins/beta/datasets/src/api/showDataset.js index abafeb37..f0ff7d4f 100644 --- a/plugins/beta/datasets/src/api/showDataset.js +++ b/plugins/beta/datasets/src/api/showDataset.js @@ -1,4 +1,14 @@ export const showDataset = ({ pluginState }, datasetId) => { pluginState.layerAdapter?.showDataset(datasetId) pluginState.dispatch({ type: 'SET_DATASET_VISIBILITY', payload: { id: datasetId, visibility: 'visible' } }) + + // Re-hide any sublayers that were individually hidden before the dataset was hidden + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (dataset?.sublayerVisibility) { + Object.entries(dataset.sublayerVisibility).forEach(([sublayerId, visibility]) => { + if (visibility === 'hidden') { + pluginState.layerAdapter?.hideSublayer(datasetId, sublayerId) + } + }) + } } diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index 71fe81dc..341ce912 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -15,6 +15,10 @@ import { setStyle } from './api/setStyle.js' import { setSublayerStyle } from './api/setSublayerStyle.js' import { getStyle } from './api/getStyle.js' import { getSublayerStyle } from './api/getSublayerStyle.js' +import { setOpacity } from './api/setOpacity.js' +import { getOpacity } from './api/getOpacity.js' +import { setSublayerOpacity } from './api/setSublayerOpacity.js' +import { getSublayerOpacity } from './api/getSublayerOpacity.js' import { setData } from './api/setData.js' export const manifest = { @@ -124,6 +128,10 @@ export const manifest = { hideSublayer, getStyle, getSublayerStyle, + setOpacity, + getOpacity, + setSublayerOpacity, + getSublayerOpacity, setStyle, setSublayerStyle, setData diff --git a/plugins/beta/datasets/src/reducer.js b/plugins/beta/datasets/src/reducer.js index dfda6eeb..3a4201fd 100755 --- a/plugins/beta/datasets/src/reducer.js +++ b/plugins/beta/datasets/src/reducer.js @@ -142,6 +142,44 @@ const setSublayerStyle = (state, payload) => { } } +const setOpacity = (state, payload) => { + const { datasetId, opacity } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => + dataset.id === datasetId ? { ...dataset, opacity } : dataset + ) + } +} + +const setGlobalOpacity = (state, payload) => { + const { opacity } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => ({ ...dataset, opacity })) + } +} + +const setSublayerOpacity = (state, payload) => { + const { datasetId, sublayerId, opacity } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => { + if (dataset.id !== datasetId) { + return dataset + } + return { + ...dataset, + sublayers: dataset.sublayers?.map(sublayer => + sublayer.id === sublayerId + ? { ...sublayer, style: { ...sublayer.style, opacity } } + : sublayer + ) + } + }) + } +} + const setLayerAdapter = (state, payload) => ({ ...state, layerAdapter: payload }) const actions = { @@ -152,6 +190,9 @@ const actions = { SET_SUBLAYER_VISIBILITY: setSublayerVisibility, SET_DATASET_STYLE: setDatasetStyle, SET_SUBLAYER_STYLE: setSublayerStyle, + SET_OPACITY: setOpacity, + SET_GLOBAL_OPACITY: setGlobalOpacity, + SET_SUBLAYER_OPACITY: setSublayerOpacity, HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, SET_LAYER_ADAPTER: setLayerAdapter From 4ae4155c17fbbda6e91196de09fd6524c9bd9b42 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 30 Mar 2026 09:43:35 +0100 Subject: [PATCH 13/14] Datasets method signatures amend --- demo/js/draw.js | 4 +- demo/js/farming.js | 27 +- demo/js/index.js | 6 +- docs/plugins/datasets.md | 302 +++++------------- plugins/beta/datasets/src/api/getOpacity.js | 19 +- plugins/beta/datasets/src/api/getStyle.js | 8 +- .../datasets/src/api/getSublayerOpacity.js | 9 - .../beta/datasets/src/api/getSublayerStyle.js | 11 - plugins/beta/datasets/src/api/hideDataset.js | 4 - plugins/beta/datasets/src/api/hideFeatures.js | 19 -- plugins/beta/datasets/src/api/hideSublayer.js | 4 - plugins/beta/datasets/src/api/setData.js | 2 +- .../datasets/src/api/setDatasetVisibility.js | 37 +++ .../datasets/src/api/setFeatureVisibility.js | 22 ++ plugins/beta/datasets/src/api/setOpacity.js | 33 +- plugins/beta/datasets/src/api/setStyle.js | 15 +- .../datasets/src/api/setSublayerOpacity.js | 9 - .../beta/datasets/src/api/setSublayerStyle.js | 16 - plugins/beta/datasets/src/api/showDataset.js | 14 - plugins/beta/datasets/src/api/showFeatures.js | 21 -- plugins/beta/datasets/src/api/showSublayer.js | 4 - plugins/beta/datasets/src/manifest.js | 26 +- plugins/beta/datasets/src/panels/Layers.jsx | 17 +- plugins/beta/datasets/src/reducer.js | 9 + 24 files changed, 236 insertions(+), 402 deletions(-) delete mode 100644 plugins/beta/datasets/src/api/getSublayerOpacity.js delete mode 100644 plugins/beta/datasets/src/api/getSublayerStyle.js delete mode 100644 plugins/beta/datasets/src/api/hideDataset.js delete mode 100644 plugins/beta/datasets/src/api/hideFeatures.js delete mode 100644 plugins/beta/datasets/src/api/hideSublayer.js create mode 100644 plugins/beta/datasets/src/api/setDatasetVisibility.js create mode 100644 plugins/beta/datasets/src/api/setFeatureVisibility.js delete mode 100644 plugins/beta/datasets/src/api/setSublayerOpacity.js delete mode 100644 plugins/beta/datasets/src/api/setSublayerStyle.js delete mode 100644 plugins/beta/datasets/src/api/showDataset.js delete mode 100644 plugins/beta/datasets/src/api/showFeatures.js delete mode 100644 plugins/beta/datasets/src/api/showSublayer.js diff --git a/demo/js/draw.js b/demo/js/draw.js index a4a4c6c8..83717cf4 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -210,8 +210,8 @@ interactiveMap.on('map:ready', function (e) { }) interactiveMap.on('datasets:ready', function () { - // setTimeout(() => datasetsPlugin.hideDataset('field-parcels'), 2000) - // setTimeout(() => datasetsPlugin.showDataset('field-parcels'), 4000) + // setTimeout(() => datasetsPlugin.setDatasetVisibility(false, { datasetId: 'field-parcels' }), 2000) + // setTimeout(() => datasetsPlugin.setDatasetVisibility(true, { datasetId: 'field-parcels' }), 4000) }) // Ref to the selected features diff --git a/demo/js/farming.js b/demo/js/farming.js index bbdbccc2..04b9fe9e 100755 --- a/demo/js/farming.js +++ b/demo/js/farming.js @@ -21,10 +21,10 @@ var feature = { id: 'test1234', type: 'Feature', geometry: { coordinates: [[[-2. var interactPlugin = createInteractPlugin({ dataLayers: [{ layerId: 'field-parcels', - idProperty: 'gid' + // idProperty: 'gid' },{ layerId: 'linked-parcels', - idProperty: 'gid' + // idProperty: 'gid' }], interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker' multiSelect: true, @@ -52,20 +52,15 @@ var datasetsPlugin = createDatasetsPlugin({ maxZoom: 24, showInKey: true, toggleVisibility: true, - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - strokeWidth: 2, - // strokeDashArray: [1, 2], - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], - fill: 'rgba(0,0,255,0.1)', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', - opacity: 0.5 + style: { + stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + strokeWidth: 2, + fill: 'rgba(0,0,255,0.1)', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent', + opacity: 0.5 + } }, // { // id: 'linked-parcels', diff --git a/demo/js/index.js b/demo/js/index.js index 5f40c0fc..bab9563f 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -270,9 +270,9 @@ interactiveMap.on('map:ready', function (e) { }) interactiveMap.on('datasets:ready', function () { - // setTimeout(() => datasetsPlugin.hideFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 2000) - // setTimeout(() => datasetsPlugin.showFeatures({ featureIds: [55], idProperty: null, datasetId: 'field-parcels' }), 4000) - // setTimeout(() => datasetsPlugin.setSublayerStyle({ datasetId: 'field-parcels', sublayerId: '130', style: { stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } } }), 2000) + // setTimeout(() => datasetsPlugin.setFeatureVisibility(false, [55], { datasetId: 'field-parcels', idProperty: null }), 2000) + // setTimeout(() => datasetsPlugin.setFeatureVisibility(true, [55], { datasetId: 'field-parcels', idProperty: null }), 4000) + // setTimeout(() => datasetsPlugin.setStyle({ stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } }, { datasetId: 'field-parcels', sublayerId: '130' }), 2000) }) // Ref to the selected features diff --git a/docs/plugins/datasets.md b/docs/plugins/datasets.md index 3d6c71d2..4d719de6 100644 --- a/docs/plugins/datasets.md +++ b/docs/plugins/datasets.md @@ -209,23 +209,8 @@ Maximum zoom level at which the dataset is visible. ### `maxFeatures` **Type:** `number` -**Default:** none — omitting this option disables eviction entirely -Only applies to dynamic sources (those using `transformRequest`). When set, the plugin tracks how many features are held in memory across all viewport fetches and evicts older features once the limit is exceeded. - -Eviction triggers at 120% of `maxFeatures` to avoid running on every fetch when hovering near the limit. Out-of-viewport features are evicted first, sorted by how recently they were visible. Features currently in the viewport are only evicted if out-of-viewport eviction alone is not sufficient. When features are evicted, the plugin resets its tracked fetch area so those regions will be re-fetched if the user pans back. - -**When to set it:** omit `maxFeatures` for small or bounded datasets where accumulation is not a concern. Set it when your dataset is large enough that features could accumulate significantly over a long session — for example a national-scale dataset at medium zoom, or any dataset where users are expected to pan extensively. - -```js -{ - id: 'my-parcels', - geojson: 'https://example.com/api/parcels', - transformRequest: transformDataRequest, - idProperty: 'id', - maxFeatures: 10000 -} -``` +Only applies to dynamic sources (those using `transformRequest`). Caps the number of features held in memory across all viewport fetches — older out-of-viewport features are evicted when the limit is exceeded. Omit for small or bounded datasets; set it when users are likely to pan extensively over a large dataset. --- @@ -355,14 +340,16 @@ sublayers: [ Methods are called on the plugin instance after the `datasets:ready` event. +The API follows a consistent pattern: the primary value is the first argument, with an optional scope object as the second argument. Omitting the scope applies the operation globally where supported. + --- ### `addDataset(dataset)` Add a new dataset to the map at runtime. -| Parameter | Type | Description | -|-----------|------|-------------| +| Argument | Type | Description | +|----------|------|-------------| | `dataset` | `Dataset` | Dataset configuration object. Accepts the same properties as `datasets` array entries | ```js @@ -382,8 +369,8 @@ interactiveMap.on('datasets:ready', () => { Remove a dataset from the map. -| Parameter | Type | Description | -|-----------|------|-------------| +| Argument | Type | Description | +|----------|------|-------------| | `datasetId` | `string` | ID of the dataset to remove | ```js @@ -392,278 +379,163 @@ datasetsPlugin.removeDataset('my-parcels') --- -### `showDataset(datasetId)` - -Make a hidden dataset visible. If the dataset has sublayers, any that were individually hidden before the dataset was hidden will remain hidden — their individual visibility state is preserved. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `datasetId` | `string` | ID of the dataset | +### `setDatasetVisibility(visible, scope?)` -```js -datasetsPlugin.showDataset('my-parcels') -``` - ---- - -### `hideDataset(datasetId)` - -Hide a visible dataset and all its sublayers. Individual sublayer visibility state is preserved so it can be correctly restored when the dataset is shown again. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `datasetId` | `string` | ID of the dataset | - -```js -datasetsPlugin.hideDataset('my-parcels') -``` - ---- +Set the visibility of datasets or sublayers. Omit `scope` to apply to all datasets globally. -### `showSublayer(datasetId, sublayerId)` +When showing a dataset that has sublayers, any sublayers that were individually hidden before the dataset was hidden will remain hidden — their individual visibility state is preserved. -Make a hidden sublayer visible. Has no effect if the parent dataset is currently hidden. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `datasetId` | `string` | ID of the dataset | -| `sublayerId` | `string` | ID of the sublayer | - -```js -datasetsPlugin.showSublayer('my-parcels', 'active') -``` - ---- - -### `hideSublayer(datasetId, sublayerId)` - -Hide a single sublayer without affecting the parent dataset or other sublayers. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `datasetId` | `string` | ID of the dataset | -| `sublayerId` | `string` | ID of the sublayer | +| Argument | Type | Description | +|----------|------|-------------| +| `visible` | `boolean` | `true` to show, `false` to hide | +| `scope.datasetId` | `string` | Optional. When omitted, applies to all datasets | +| `scope.sublayerId` | `string` | Optional. When provided alongside `datasetId`, targets a single sublayer | ```js -datasetsPlugin.hideSublayer('my-parcels', 'active') -``` - ---- - -### `showFeatures(options)` - -Show previously hidden features within a dataset. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `options.datasetId` | `string` | ID of the dataset | -| `options.featureIds` | `(string \| number)[]` | IDs of features to show | -| `options.idProperty` | `string \| null` | Property name to match features on. Pass `null` to match against the top-level `feature.id` instead | +// Global — all datasets +datasetsPlugin.setDatasetVisibility(false) +datasetsPlugin.setDatasetVisibility(true) -```js -// Match by a feature property -datasetsPlugin.showFeatures({ - datasetId: 'my-parcels', - featureIds: [123, 456], - idProperty: 'parcel_id' -}) +// Single dataset +datasetsPlugin.setDatasetVisibility(false, { datasetId: 'my-parcels' }) -// Match by feature.id (no property needed) -datasetsPlugin.showFeatures({ - datasetId: 'my-parcels', - featureIds: [123, 456], - idProperty: null -}) +// Single sublayer +datasetsPlugin.setDatasetVisibility(false, { datasetId: 'my-parcels', sublayerId: 'active' }) ``` --- -### `hideFeatures(options)` +### `setFeatureVisibility(visible, featureIds, scope)` -Hide specific features within a dataset without removing them from the source. +Show or hide specific features within a dataset without removing them from the source. -| Parameter | Type | Description | -|-----------|------|-------------| -| `options.datasetId` | `string` | ID of the dataset | -| `options.featureIds` | `(string \| number)[]` | IDs of features to hide | -| `options.idProperty` | `string \| null` | Property name to match features on. Pass `null` to match against the top-level `feature.id` instead | +| Argument | Type | Description | +|----------|------|-------------| +| `visible` | `boolean` | `true` to show, `false` to hide | +| `featureIds` | `(string \| number)[]` | IDs of the features to target | +| `scope.datasetId` | `string` | ID of the dataset | +| `scope.idProperty` | `string \| null` | Property name to match features on. Pass `null` to match against the top-level `feature.id` | ```js -// Match by a feature property -datasetsPlugin.hideFeatures({ +// Hide by a feature property +datasetsPlugin.setFeatureVisibility(false, [123, 456], { datasetId: 'my-parcels', - featureIds: [123, 456], idProperty: 'parcel_id' }) -// Match by feature.id (no property needed) -datasetsPlugin.hideFeatures({ +// Show using feature.id +datasetsPlugin.setFeatureVisibility(true, [123, 456], { datasetId: 'my-parcels', - featureIds: [123, 456], idProperty: null }) ``` --- -### `setStyle(options)` +### `setStyle(style, scope)` -Update the visual style of a dataset at runtime. Affects only properties that sublayers do not themselves override. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `options.datasetId` | `string` | ID of the dataset | -| `options.style` | `Object` | Style properties to apply. Accepts the same properties as `dataset.style` | - -```js -datasetsPlugin.setStyle({ - datasetId: 'my-parcels', - style: { - stroke: '#0000ff', - strokeWidth: 3 - } -}) -``` - ---- +Update the visual style of a dataset or sublayer at runtime. When targeting a sublayer, only the properties specified are overridden — the sublayer inherits all other styles from the parent dataset. -### `setSublayerStyle(options)` - -Update the visual style of a specific sublayer at runtime. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `options.datasetId` | `string` | ID of the dataset | -| `options.sublayerId` | `string` | ID of the sublayer | -| `options.style` | `Object` | Style properties to apply. Accepts the same properties as `sublayer.style` | +| Argument | Type | Description | +|----------|------|-------------| +| `style` | `Object` | Style properties to apply. Accepts the same properties as `dataset.style` | +| `scope.datasetId` | `string` | ID of the dataset | +| `scope.sublayerId` | `string` | Optional. When provided, targets a single sublayer | ```js -datasetsPlugin.setSublayerStyle({ - datasetId: 'my-parcels', - sublayerId: 'active', - style: { - stroke: '#00703c', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: '#00703c' - } -}) +// Dataset level +datasetsPlugin.setStyle( + { stroke: '#0000ff', strokeWidth: 3 }, + { datasetId: 'my-parcels' } +) + +// Sublayer level +datasetsPlugin.setStyle( + { stroke: '#00703c', fillPattern: 'diagonal-cross-hatch', fillPatternForegroundColor: '#00703c' }, + { datasetId: 'my-parcels', sublayerId: 'active' } +) ``` --- -### `getStyle(options)` +### `getStyle(scope)` -Returns the current style object for a dataset, or `null` if the dataset is not found. +Returns the current style object for a dataset or sublayer, or `null` if not found. -| Parameter | Type | Description | -|-----------|------|-------------| -| `options.datasetId` | `string` | ID of the dataset | +| Argument | Type | Description | +|----------|------|-------------| +| `scope.datasetId` | `string` | ID of the dataset | +| `scope.sublayerId` | `string` | Optional. When provided, returns the sublayer's style | ```js +// Dataset style const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels' }) -console.log(style) // { stroke: '#d4351c', strokeWidth: 2, ... } -``` - ---- -### `getSublayerStyle(options)` - -Returns the current style object for a sublayer, or `null` if the dataset or sublayer is not found. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `options.datasetId` | `string` | ID of the dataset | -| `options.sublayerId` | `string` | ID of the sublayer | - -```js -const style = datasetsPlugin.getSublayerStyle({ datasetId: 'my-parcels', sublayerId: 'active' }) -console.log(style) // { stroke: '#00703c', fill: 'rgba(0,112,60,0.1)' } +// Sublayer style +const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels', sublayerId: 'active' }) ``` --- -### `setOpacity(opacity)` / `setOpacity(datasetId, opacity)` +### `setOpacity(opacity, scope?)` -Set the opacity of all datasets or a single dataset. Safe to call on every tick from a slider — uses `setPaintProperty` internally rather than removing and re-adding layers. +Set the opacity of datasets or a sublayer. Safe to call on every tick from a slider — uses `setPaintProperty` internally rather than removing and re-adding layers. Omit `scope` to apply globally. | Argument | Type | Description | |----------|------|-------------| -| `opacity` | `number` | Opacity from `0` (transparent) to `1` (fully opaque). Omit `datasetId` to apply globally | -| `datasetId` | `string` | Optional. When provided, only that dataset is affected | +| `opacity` | `number` | Opacity from `0` (transparent) to `1` (fully opaque) | +| `scope.datasetId` | `string` | Optional. When omitted, applies to all datasets | +| `scope.sublayerId` | `string` | Optional. When provided alongside `datasetId`, targets a single sublayer | ```js // Global — all datasets datasetsPlugin.setOpacity(0.5) // Single dataset -datasetsPlugin.setOpacity('my-parcels', 0.5) +datasetsPlugin.setOpacity(0.5, { datasetId: 'my-parcels' }) + +// Single sublayer +datasetsPlugin.setOpacity(0.5, { datasetId: 'my-parcels', sublayerId: 'active' }) ``` --- -### `getOpacity()` / `getOpacity(datasetId)` +### `getOpacity(scope?)` -Returns the current opacity for a dataset, or the first dataset's opacity when called without arguments. Returns `null` if the dataset is not found. +Returns the current opacity for a dataset or sublayer. When called without arguments, returns the first dataset's opacity — useful for initialising a global slider. Returns `null` if not found. | Argument | Type | Description | |----------|------|-------------| -| `datasetId` | `string` | Optional. When omitted, returns the opacity of the first dataset | +| `scope.datasetId` | `string` | Optional. When omitted, returns the first dataset's opacity | +| `scope.sublayerId` | `string` | Optional. When provided alongside `datasetId`, returns the sublayer's opacity | ```js -// Read back after setting globally — useful for initialising a slider +// Global — read back after setOpacity() for slider initialisation const opacity = datasetsPlugin.getOpacity() // Single dataset -const opacity = datasetsPlugin.getOpacity('my-parcels') -``` - ---- - -### `setSublayerOpacity(datasetId, sublayerId, opacity)` - -Set the opacity of a single sublayer. Safe to call on every tick from a slider. - -| Argument | Type | Description | -|----------|------|-------------| -| `datasetId` | `string` | ID of the dataset | -| `sublayerId` | `string` | ID of the sublayer | -| `opacity` | `number` | Opacity from `0` to `1` | +const opacity = datasetsPlugin.getOpacity({ datasetId: 'my-parcels' }) -```js -datasetsPlugin.setSublayerOpacity('my-parcels', 'active', 0.5) +// Single sublayer +const opacity = datasetsPlugin.getOpacity({ datasetId: 'my-parcels', sublayerId: 'active' }) ``` --- -### `getSublayerOpacity(datasetId, sublayerId)` +### `setData(geojson, scope)` -Returns the current opacity for a sublayer, or `null` if the dataset or sublayer is not found. +Replace the GeoJSON data for a dataset source. Has no effect on vector tile datasets. | Argument | Type | Description | |----------|------|-------------| -| `datasetId` | `string` | ID of the dataset | -| `sublayerId` | `string` | ID of the sublayer | - -```js -const opacity = datasetsPlugin.getSublayerOpacity('my-parcels', 'active') -``` - ---- - -### `setData(options)` - -Replace the GeoJSON data for a dataset source. Has no effect on vector tile datasets — use `transformRequest` for those. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `options.datasetId` | `string` | ID of the dataset | -| `options.geojson` | `GeoJSON.FeatureCollection` | New GeoJSON data | +| `geojson` | `GeoJSON.FeatureCollection` | New GeoJSON data | +| `scope.datasetId` | `string` | ID of the dataset | ```js -datasetsPlugin.setData({ - datasetId: 'my-parcels', - geojson: { type: 'FeatureCollection', features: [...] } -}) +datasetsPlugin.setData( + { type: 'FeatureCollection', features: [...] }, + { datasetId: 'my-parcels' } +) ``` --- @@ -684,6 +556,6 @@ Emitted once all datasets have been initialised and rendered on the map. interactiveMap.on('datasets:ready', () => { console.log('Datasets are ready') // Safe to call API methods from here - const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels' }) + const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels' }) // unchanged — scope object }) ``` diff --git a/plugins/beta/datasets/src/api/getOpacity.js b/plugins/beta/datasets/src/api/getOpacity.js index 1a033ee2..7b0ae58e 100644 --- a/plugins/beta/datasets/src/api/getOpacity.js +++ b/plugins/beta/datasets/src/api/getOpacity.js @@ -1,8 +1,17 @@ -export const getOpacity = ({ pluginState }, datasetId) => { - if (!datasetId) { - return pluginState.datasets?.[0]?.opacity ?? null +export const getOpacity = ({ pluginState }, options) => { + const { datasetId, sublayerId } = options || {} + + if (sublayerId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + const sublayer = dataset?.sublayers?.find(s => s.id === sublayerId) + return sublayer?.style?.opacity ?? null + } + + if (datasetId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + return dataset?.opacity ?? null } - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - return dataset?.opacity ?? null + // Global — return first dataset's opacity + return pluginState.datasets?.[0]?.opacity ?? null } diff --git a/plugins/beta/datasets/src/api/getStyle.js b/plugins/beta/datasets/src/api/getStyle.js index 199c55f7..f6ac0a5c 100644 --- a/plugins/beta/datasets/src/api/getStyle.js +++ b/plugins/beta/datasets/src/api/getStyle.js @@ -1,7 +1,13 @@ -export const getStyle = ({ pluginState }, { datasetId }) => { +export const getStyle = ({ pluginState }, { datasetId, sublayerId } = {}) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) if (!dataset) { return null } + + if (sublayerId) { + const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) + return sublayer?.style ?? null + } + return dataset.style ?? null } diff --git a/plugins/beta/datasets/src/api/getSublayerOpacity.js b/plugins/beta/datasets/src/api/getSublayerOpacity.js deleted file mode 100644 index d2c46dca..00000000 --- a/plugins/beta/datasets/src/api/getSublayerOpacity.js +++ /dev/null @@ -1,9 +0,0 @@ -export const getSublayerOpacity = ({ pluginState }, datasetId, sublayerId) => { - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { - return null - } - - const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) - return sublayer?.style?.opacity ?? null -} diff --git a/plugins/beta/datasets/src/api/getSublayerStyle.js b/plugins/beta/datasets/src/api/getSublayerStyle.js deleted file mode 100644 index f46e789e..00000000 --- a/plugins/beta/datasets/src/api/getSublayerStyle.js +++ /dev/null @@ -1,11 +0,0 @@ -export const getSublayerStyle = ({ pluginState }, { datasetId, sublayerId }) => { - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { - return null - } - const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) - if (!sublayer) { - return null - } - return sublayer.style ?? null -} diff --git a/plugins/beta/datasets/src/api/hideDataset.js b/plugins/beta/datasets/src/api/hideDataset.js deleted file mode 100644 index 7943dede..00000000 --- a/plugins/beta/datasets/src/api/hideDataset.js +++ /dev/null @@ -1,4 +0,0 @@ -export const hideDataset = ({ pluginState }, datasetId) => { - pluginState.layerAdapter?.hideDataset(datasetId) - pluginState.dispatch({ type: 'SET_DATASET_VISIBILITY', payload: { id: datasetId, visibility: 'hidden' } }) -} diff --git a/plugins/beta/datasets/src/api/hideFeatures.js b/plugins/beta/datasets/src/api/hideFeatures.js deleted file mode 100644 index b2486ed8..00000000 --- a/plugins/beta/datasets/src/api/hideFeatures.js +++ /dev/null @@ -1,19 +0,0 @@ -export const hideFeatures = ({ pluginState }, { featureIds, idProperty, datasetId }) => { - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { - return - } - - const existingHidden = pluginState.hiddenFeatures[datasetId] - const allHiddenIds = existingHidden - ? [...new Set([...existingHidden.ids, ...featureIds])] - : featureIds - - // Update state (store by datasetId, not individual layer IDs) - pluginState.dispatch({ - type: 'HIDE_FEATURES', - payload: { layerId: datasetId, idProperty, featureIds } - }) - - pluginState.layerAdapter?.hideFeatures(dataset, idProperty, allHiddenIds) -} diff --git a/plugins/beta/datasets/src/api/hideSublayer.js b/plugins/beta/datasets/src/api/hideSublayer.js deleted file mode 100644 index a0837efa..00000000 --- a/plugins/beta/datasets/src/api/hideSublayer.js +++ /dev/null @@ -1,4 +0,0 @@ -export const hideSublayer = ({ pluginState }, datasetId, sublayerId) => { - pluginState.layerAdapter?.hideSublayer(datasetId, sublayerId) - pluginState.dispatch({ type: 'SET_SUBLAYER_VISIBILITY', payload: { datasetId, sublayerId, visibility: 'hidden' } }) -} diff --git a/plugins/beta/datasets/src/api/setData.js b/plugins/beta/datasets/src/api/setData.js index 6503a892..9f718321 100644 --- a/plugins/beta/datasets/src/api/setData.js +++ b/plugins/beta/datasets/src/api/setData.js @@ -1,4 +1,4 @@ -export const setData = ({ pluginState, services }, { datasetId, geojson }) => { +export const setData = ({ pluginState, services }, geojson, { datasetId }) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) if (dataset?.tiles) { services.logger.warn(`setData called on vector tile dataset "${datasetId}" — has no effect`) diff --git a/plugins/beta/datasets/src/api/setDatasetVisibility.js b/plugins/beta/datasets/src/api/setDatasetVisibility.js new file mode 100644 index 00000000..d3be96a5 --- /dev/null +++ b/plugins/beta/datasets/src/api/setDatasetVisibility.js @@ -0,0 +1,37 @@ +export const setDatasetVisibility = ({ pluginState }, visible, options = {}) => { + const { datasetId, sublayerId } = options + + if (sublayerId) { + const visibility = visible ? 'visible' : 'hidden' + pluginState.layerAdapter?.[visible ? 'showSublayer' : 'hideSublayer'](datasetId, sublayerId) + pluginState.dispatch({ type: 'SET_SUBLAYER_VISIBILITY', payload: { datasetId, sublayerId, visibility } }) + return + } + + if (datasetId) { + pluginState.layerAdapter?.[visible ? 'showDataset' : 'hideDataset'](datasetId) + pluginState.dispatch({ type: 'SET_DATASET_VISIBILITY', payload: { id: datasetId, visibility: visible ? 'visible' : 'hidden' } }) + if (visible) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + Object.entries(dataset?.sublayerVisibility || {}).forEach(([subId, vis]) => { + if (vis === 'hidden') { + pluginState.layerAdapter?.hideSublayer(datasetId, subId) + } + }) + } + return + } + + // Global + pluginState.dispatch({ type: 'SET_GLOBAL_VISIBILITY', payload: { visibility: visible ? 'visible' : 'hidden' } }) + pluginState.datasets?.forEach(dataset => { + pluginState.layerAdapter?.[visible ? 'showDataset' : 'hideDataset'](dataset.id) + if (visible) { + Object.entries(dataset.sublayerVisibility || {}).forEach(([subId, vis]) => { + if (vis === 'hidden') { + pluginState.layerAdapter?.hideSublayer(dataset.id, subId) + } + }) + } + }) +} diff --git a/plugins/beta/datasets/src/api/setFeatureVisibility.js b/plugins/beta/datasets/src/api/setFeatureVisibility.js new file mode 100644 index 00000000..cbc5a992 --- /dev/null +++ b/plugins/beta/datasets/src/api/setFeatureVisibility.js @@ -0,0 +1,22 @@ +export const setFeatureVisibility = ({ pluginState }, visible, featureIds, { datasetId, idProperty = null } = {}) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + + if (visible) { + const existingHidden = pluginState.hiddenFeatures[datasetId] + if (!existingHidden) { + return + } + const remainingHiddenIds = existingHidden.ids.filter(id => !featureIds.includes(id)) + pluginState.dispatch({ type: 'SHOW_FEATURES', payload: { layerId: datasetId, featureIds } }) + pluginState.layerAdapter?.showFeatures(dataset, idProperty, remainingHiddenIds) + } else { + const existingHidden = pluginState.hiddenFeatures[datasetId] + const existingIds = existingHidden?.ids || [] + const allHiddenIds = [...new Set([...existingIds, ...featureIds])] + pluginState.dispatch({ type: 'HIDE_FEATURES', payload: { layerId: datasetId, idProperty, featureIds } }) + pluginState.layerAdapter?.hideFeatures(dataset, idProperty, allHiddenIds) + } +} diff --git a/plugins/beta/datasets/src/api/setOpacity.js b/plugins/beta/datasets/src/api/setOpacity.js index b41f94ec..e3b5cec3 100644 --- a/plugins/beta/datasets/src/api/setOpacity.js +++ b/plugins/beta/datasets/src/api/setOpacity.js @@ -1,18 +1,29 @@ -export const setOpacity = ({ pluginState }, datasetIdOrOpacity, opacity) => { - if (typeof datasetIdOrOpacity === 'number') { - pluginState.dispatch({ type: 'SET_GLOBAL_OPACITY', payload: { opacity: datasetIdOrOpacity } }) - pluginState.datasets?.forEach(d => { - pluginState.layerAdapter?.setOpacity(d.id, datasetIdOrOpacity) - }) +export const setOpacity = ({ pluginState }, opacity, options) => { + const { datasetId, sublayerId } = options || {} + + if (sublayerId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + pluginState.dispatch({ type: 'SET_SUBLAYER_OPACITY', payload: { datasetId, sublayerId, opacity } }) + pluginState.layerAdapter?.setSublayerOpacity(datasetId, sublayerId, opacity) return } - const datasetId = datasetIdOrOpacity - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { + if (datasetId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + pluginState.dispatch({ type: 'SET_OPACITY', payload: { datasetId, opacity } }) + pluginState.layerAdapter?.setOpacity(datasetId, opacity) return } - pluginState.dispatch({ type: 'SET_OPACITY', payload: { datasetId, opacity } }) - pluginState.layerAdapter?.setOpacity(datasetId, opacity) + // Global + pluginState.dispatch({ type: 'SET_GLOBAL_OPACITY', payload: { opacity } }) + pluginState.datasets?.forEach(d => { + pluginState.layerAdapter?.setOpacity(d.id, opacity) + }) } diff --git a/plugins/beta/datasets/src/api/setStyle.js b/plugins/beta/datasets/src/api/setStyle.js index 07891cc0..26c9473d 100644 --- a/plugins/beta/datasets/src/api/setStyle.js +++ b/plugins/beta/datasets/src/api/setStyle.js @@ -1,11 +1,22 @@ -export const setStyle = ({ pluginState, mapState }, { datasetId, style }) => { +export const setStyle = ({ pluginState, mapState }, style, { datasetId, sublayerId } = {}) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) if (!dataset) { return } - pluginState.dispatch({ type: 'SET_DATASET_STYLE', payload: { datasetId, styleChanges: style } }) + if (sublayerId) { + pluginState.dispatch({ type: 'SET_SUBLAYER_STYLE', payload: { datasetId, sublayerId, styleChanges: style } }) + const updatedSublayerDataset = { + ...dataset, + sublayers: dataset.sublayers?.map(sublayer => + sublayer.id === sublayerId ? { ...sublayer, style: { ...sublayer.style, ...style } } : sublayer + ) + } + pluginState.layerAdapter?.setSublayerStyle(updatedSublayerDataset, sublayerId, mapState.mapStyle.id) + return + } + pluginState.dispatch({ type: 'SET_DATASET_STYLE', payload: { datasetId, styleChanges: style } }) const updatedDataset = { ...dataset, ...style } pluginState.layerAdapter?.setStyle(updatedDataset, mapState.mapStyle.id) } diff --git a/plugins/beta/datasets/src/api/setSublayerOpacity.js b/plugins/beta/datasets/src/api/setSublayerOpacity.js deleted file mode 100644 index 44165c19..00000000 --- a/plugins/beta/datasets/src/api/setSublayerOpacity.js +++ /dev/null @@ -1,9 +0,0 @@ -export const setSublayerOpacity = ({ pluginState }, datasetId, sublayerId, opacity) => { - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { - return - } - - pluginState.dispatch({ type: 'SET_SUBLAYER_OPACITY', payload: { datasetId, sublayerId, opacity } }) - pluginState.layerAdapter?.setSublayerOpacity(datasetId, sublayerId, opacity) -} diff --git a/plugins/beta/datasets/src/api/setSublayerStyle.js b/plugins/beta/datasets/src/api/setSublayerStyle.js deleted file mode 100644 index 9603aa81..00000000 --- a/plugins/beta/datasets/src/api/setSublayerStyle.js +++ /dev/null @@ -1,16 +0,0 @@ -export const setSublayerStyle = ({ pluginState, mapState }, { datasetId, sublayerId, style }) => { - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { - return - } - - pluginState.dispatch({ type: 'SET_SUBLAYER_STYLE', payload: { datasetId, sublayerId, styleChanges: style } }) - - const updatedDataset = { - ...dataset, - sublayers: dataset.sublayers?.map(sublayer => - sublayer.id === sublayerId ? { ...sublayer, style: { ...sublayer.style, ...style } } : sublayer - ) - } - pluginState.layerAdapter?.setSublayerStyle(updatedDataset, sublayerId, mapState.mapStyle.id) -} diff --git a/plugins/beta/datasets/src/api/showDataset.js b/plugins/beta/datasets/src/api/showDataset.js deleted file mode 100644 index f0ff7d4f..00000000 --- a/plugins/beta/datasets/src/api/showDataset.js +++ /dev/null @@ -1,14 +0,0 @@ -export const showDataset = ({ pluginState }, datasetId) => { - pluginState.layerAdapter?.showDataset(datasetId) - pluginState.dispatch({ type: 'SET_DATASET_VISIBILITY', payload: { id: datasetId, visibility: 'visible' } }) - - // Re-hide any sublayers that were individually hidden before the dataset was hidden - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (dataset?.sublayerVisibility) { - Object.entries(dataset.sublayerVisibility).forEach(([sublayerId, visibility]) => { - if (visibility === 'hidden') { - pluginState.layerAdapter?.hideSublayer(datasetId, sublayerId) - } - }) - } -} diff --git a/plugins/beta/datasets/src/api/showFeatures.js b/plugins/beta/datasets/src/api/showFeatures.js deleted file mode 100644 index 2ec14b23..00000000 --- a/plugins/beta/datasets/src/api/showFeatures.js +++ /dev/null @@ -1,21 +0,0 @@ -export const showFeatures = ({ pluginState }, { featureIds, idProperty, datasetId }) => { - const existingHidden = pluginState.hiddenFeatures[datasetId] - if (!existingHidden) { - return - } - - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) { - return - } - - const remainingHiddenIds = existingHidden.ids.filter(id => !featureIds.includes(id)) - - // Update state - pluginState.dispatch({ - type: 'SHOW_FEATURES', - payload: { layerId: datasetId, featureIds } - }) - - pluginState.layerAdapter?.showFeatures(dataset, idProperty, remainingHiddenIds) -} diff --git a/plugins/beta/datasets/src/api/showSublayer.js b/plugins/beta/datasets/src/api/showSublayer.js deleted file mode 100644 index 1680c8ab..00000000 --- a/plugins/beta/datasets/src/api/showSublayer.js +++ /dev/null @@ -1,4 +0,0 @@ -export const showSublayer = ({ pluginState }, datasetId, sublayerId) => { - pluginState.layerAdapter?.showSublayer(datasetId, sublayerId) - pluginState.dispatch({ type: 'SET_SUBLAYER_VISIBILITY', payload: { datasetId, sublayerId, visibility: 'visible' } }) -} diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index 341ce912..baad1932 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -3,22 +3,14 @@ import { initialState, actions } from './reducer.js' import { DatasetsInit } from './DatasetsInit.jsx' import { Layers } from './panels/Layers.jsx' import { Key } from './panels/Key.jsx' -import { showDataset } from './api/showDataset.js' -import { hideDataset } from './api/hideDataset.js' import { addDataset } from './api/addDataset.js' import { removeDataset } from './api/removeDataset.js' -import { showFeatures } from './api/showFeatures.js' -import { hideFeatures } from './api/hideFeatures.js' -import { showSublayer } from './api/showSublayer.js' -import { hideSublayer } from './api/hideSublayer.js' +import { setDatasetVisibility } from './api/setDatasetVisibility.js' +import { setFeatureVisibility } from './api/setFeatureVisibility.js' import { setStyle } from './api/setStyle.js' -import { setSublayerStyle } from './api/setSublayerStyle.js' import { getStyle } from './api/getStyle.js' -import { getSublayerStyle } from './api/getSublayerStyle.js' import { setOpacity } from './api/setOpacity.js' import { getOpacity } from './api/getOpacity.js' -import { setSublayerOpacity } from './api/setSublayerOpacity.js' -import { getSublayerOpacity } from './api/getSublayerOpacity.js' import { setData } from './api/setData.js' export const manifest = { @@ -118,22 +110,14 @@ export const manifest = { }], api: { - showDataset, - hideDataset, addDataset, removeDataset, - showFeatures, - hideFeatures, - showSublayer, - hideSublayer, + setDatasetVisibility, + setFeatureVisibility, + setStyle, getStyle, - getSublayerStyle, setOpacity, getOpacity, - setSublayerOpacity, - getSublayerOpacity, - setStyle, - setSublayerStyle, setData } } diff --git a/plugins/beta/datasets/src/panels/Layers.jsx b/plugins/beta/datasets/src/panels/Layers.jsx index f12fa538..6c3d4e66 100755 --- a/plugins/beta/datasets/src/panels/Layers.jsx +++ b/plugins/beta/datasets/src/panels/Layers.jsx @@ -1,8 +1,5 @@ import React from 'react' -import { showDataset } from '../api/showDataset' -import { hideDataset } from '../api/hideDataset' -import { showSublayer } from '../api/showSublayer' -import { hideSublayer } from '../api/hideSublayer' +import { setDatasetVisibility } from '../api/setDatasetVisibility' const CHECKBOX_LABEL_CLASS = 'im-c-datasets-layers__item-label govuk-label govuk-checkboxes__label' @@ -42,22 +39,14 @@ const buildRenderItems = (datasets) => { export const Layers = ({ pluginState }) => { const handleDatasetChange = (e) => { const { value, checked } = e.target - if (checked) { - showDataset({ pluginState }, value) - } else { - hideDataset({ pluginState }, value) - } + setDatasetVisibility({ pluginState }, checked, { datasetId: value }) } const handleSublayerChange = (e) => { const { checked } = e.target const datasetId = e.target.dataset.datasetId const sublayerId = e.target.dataset.sublayerId - if (checked) { - showSublayer({ pluginState }, datasetId, sublayerId) - } else { - hideSublayer({ pluginState }, datasetId, sublayerId) - } + setDatasetVisibility({ pluginState }, checked, { datasetId, sublayerId }) } const renderDatasetItem = (dataset) => { diff --git a/plugins/beta/datasets/src/reducer.js b/plugins/beta/datasets/src/reducer.js index 3a4201fd..abe59162 100755 --- a/plugins/beta/datasets/src/reducer.js +++ b/plugins/beta/datasets/src/reducer.js @@ -54,6 +54,14 @@ const setDatasetVisibility = (state, payload) => { } } +const setGlobalVisibility = (state, payload) => { + const { visibility } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => ({ ...dataset, visibility })) + } +} + const hideFeatures = (state, payload) => { const { layerId, idProperty, featureIds } = payload const existing = state.hiddenFeatures[layerId] @@ -187,6 +195,7 @@ const actions = { ADD_DATASET: addDataset, REMOVE_DATASET: removeDataset, SET_DATASET_VISIBILITY: setDatasetVisibility, + SET_GLOBAL_VISIBILITY: setGlobalVisibility, SET_SUBLAYER_VISIBILITY: setSublayerVisibility, SET_DATASET_STYLE: setDatasetStyle, SET_SUBLAYER_STYLE: setSublayerStyle, From d91ca4ec6a83f62896158f2e7a5ef9932cfb57c1 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 30 Mar 2026 10:01:07 +0100 Subject: [PATCH 14/14] Maplibre bump --- package-lock.json | 135 +++++++++++++++++++++++++++------------------- package.json | 2 +- 2 files changed, 81 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e5c0ff4..f4fa5903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@turf/polygon-to-line": "^7.3.3", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", - "maplibre-gl": "^5.15.0", + "maplibre-gl": "^5.21.1", "polygon-splitter": "^0.0.11", "preact": "^10.27.2", "tslib": "^2.8.1" @@ -5303,7 +5303,9 @@ } }, "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6023,17 +6025,6 @@ "geojson-normalize": "geojson-normalize" } }, - "node_modules/@mapbox/geojson-rewind": { - "version": "0.5.2", - "license": "ISC", - "dependencies": { - "get-stream": "^6.0.1", - "minimist": "^1.2.6" - }, - "bin": { - "geojson-rewind": "geojson-rewind" - } - }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "engines": { @@ -6090,11 +6081,18 @@ } }, "node_modules/@maplibre/geojson-vt": { - "version": "5.0.4", - "license": "ISC" + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz", + "integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.4.1", + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz", + "integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==", "license": "ISC", "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", @@ -6112,14 +6110,18 @@ } }, "node_modules/@maplibre/mlt": { - "version": "1.1.5", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz", + "integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==", "license": "(MIT OR Apache-2.0)", "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "node_modules/@maplibre/vt-pbf": { - "version": "4.2.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", "license": "MIT", "dependencies": { "@mapbox/point-geometry": "^1.1.0", @@ -6131,6 +6133,12 @@ "supercluster": "^8.0.1" } }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -8029,7 +8037,9 @@ } }, "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "optional": true, @@ -8423,9 +8433,9 @@ "license": "MIT" }, "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8536,9 +8546,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11692,7 +11702,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -15979,7 +15991,9 @@ "license": "MIT" }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -16440,9 +16454,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -18661,7 +18675,9 @@ } }, "node_modules/jest-environment-jsdom/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -19305,6 +19321,8 @@ }, "node_modules/json-stringify-pretty-compact": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT" }, "node_modules/json5": { @@ -20103,22 +20121,22 @@ } }, "node_modules/maplibre-gl": { - "version": "5.17.0", + "version": "5.21.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz", + "integrity": "sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.7", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^5.0.4", - "@maplibre/maplibre-gl-style-spec": "^24.4.1", - "@maplibre/mlt": "^1.1.2", - "@maplibre/vt-pbf": "^4.2.1", + "@maplibre/geojson-vt": "^6.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.7.0", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", "@types/geojson": "^7946.0.16", - "@types/supercluster": "^7.1.3", "earcut": "^3.0.2", "gl-matrix": "^3.4.4", "kdbush": "^4.0.2", @@ -20126,7 +20144,6 @@ "pbf": "^4.0.1", "potpack": "^2.1.0", "quickselect": "^3.0.0", - "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" }, "engines": { @@ -23646,7 +23663,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/path-type": { @@ -23708,7 +23727,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -27494,9 +27515,9 @@ } }, "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -27943,9 +27964,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", - "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "license": "BSD-3-Clause", "engines": { "node": ">=20.0.0" @@ -29485,7 +29506,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -29910,9 +29933,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -30623,9 +30646,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -31448,7 +31471,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { diff --git a/package.json b/package.json index 0e7a96e9..1c20274a 100755 --- a/package.json +++ b/package.json @@ -218,7 +218,7 @@ "@turf/polygon-to-line": "^7.3.3", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", - "maplibre-gl": "^5.15.0", + "maplibre-gl": "^5.21.1", "polygon-splitter": "^0.0.11", "preact": "^10.27.2", "tslib": "^2.8.1"