@defra/interactive-map 0.0.16-alpha → 0.0.17-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/images/slot-map.svg +264 -0
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/slots.md +16 -15
- package/docs/api.md +3 -3
- package/docs/getting-started.md +4 -1
- package/docs/plugins/datasets.md +561 -0
- package/docs/plugins.md +1 -1
- package/package.json +2 -2
- package/plugins/beta/datasets/dist/css/index.css +85 -15
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/index.js +1 -1
- package/plugins/beta/datasets/src/DatasetsInit.jsx +23 -8
- package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
- package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +113 -0
- package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +69 -0
- package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +338 -0
- package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +48 -0
- package/plugins/beta/datasets/src/api/addDataset.js +2 -8
- package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
- package/plugins/beta/datasets/src/api/getStyle.js +13 -0
- package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
- package/plugins/beta/datasets/src/api/setData.js +8 -0
- package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
- package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
- package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
- package/plugins/beta/datasets/src/api/setStyle.js +22 -0
- package/plugins/beta/datasets/src/datasets.js +29 -55
- package/plugins/beta/datasets/src/defaults.js +42 -8
- package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
- package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
- package/plugins/beta/datasets/src/manifest.js +24 -16
- package/plugins/beta/datasets/src/panels/Key.jsx +128 -50
- package/plugins/beta/datasets/src/panels/Key.module.scss +48 -9
- package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
- package/plugins/beta/datasets/src/panels/Layers.module.scss +50 -8
- package/plugins/beta/datasets/src/reducer.js +128 -9
- package/plugins/beta/datasets/src/styles/patterns.js +157 -0
- package/plugins/beta/datasets/src/utils/bbox.js +7 -5
- package/plugins/beta/datasets/src/utils/filters.js +5 -2
- package/plugins/beta/datasets/src/utils/mergeSublayer.js +78 -0
- package/plugins/beta/draw-ml/dist/css/index.css +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/draw.scss +0 -7
- package/plugins/beta/draw-ml/src/manifest.js +16 -16
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/src/Frame.jsx +5 -5
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/src/manifest.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +1 -1
- package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
- package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
- package/plugins/beta/scale-bar/src/index.test.js +3 -3
- package/plugins/beta/scale-bar/src/manifest.js +3 -3
- package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
- package/plugins/interact/dist/css/index.css +1 -1
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/src/interact.scss +0 -7
- package/plugins/interact/src/manifest.js +14 -18
- package/plugins/interact/src/manifest.test.js +3 -1
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/src/components/Form/Form.module.scss +2 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/utils/highlightFeatures.js +1 -0
- package/providers/maplibre/src/utils/highlightFeatures.test.js +1 -0
- package/src/App/components/Actions/Actions.jsx +2 -2
- package/src/App/components/Actions/Actions.module.scss +0 -7
- package/src/App/components/Actions/Actions.test.jsx +1 -1
- package/src/App/components/Icon/Icon.jsx +3 -2
- package/src/App/components/Icon/Icon.module.scss +4 -0
- package/src/App/components/Icon/Icon.test.jsx +43 -4
- package/src/App/components/MapButton/MapButton.jsx +42 -17
- package/src/App/components/MapButton/MapButton.module.scss +4 -13
- package/src/App/components/MapButton/MapButton.test.jsx +27 -3
- package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
- package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
- package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
- package/src/App/hooks/useButtonStateEvaluator.js +12 -2
- package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
- package/src/App/hooks/useInterfaceAPI.js +6 -0
- package/src/App/hooks/useLayoutMeasurements.js +84 -18
- package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
- package/src/App/layout/Layout.jsx +12 -7
- package/src/App/layout/Layout.test.jsx +2 -2
- package/src/App/layout/layout.module.scss +67 -29
- package/src/App/registry/pluginRegistry.js +1 -1
- package/src/App/renderer/HtmlElementHost.jsx +2 -1
- package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
- package/src/App/renderer/mapButtons.js +1 -1
- package/src/App/renderer/mapPanels.test.js +2 -2
- package/src/App/renderer/slotHelpers.js +2 -2
- package/src/App/renderer/slotHelpers.test.js +5 -5
- package/src/App/renderer/slots.js +9 -5
- package/src/App/store/AppProvider.jsx +3 -1
- package/src/App/store/AppProvider.test.jsx +1 -1
- package/src/App/store/ServiceProvider.jsx +3 -1
- package/src/App/store/appActionsMap.js +16 -0
- package/src/App/store/appActionsMap.test.js +27 -0
- package/src/App/store/appDispatchMiddleware.js +1 -1
- package/src/App/store/appDispatchMiddleware.test.js +2 -2
- package/src/App/store/appReducer.js +2 -0
- package/src/InteractiveMap/InteractiveMap.js +4 -0
- package/src/config/appConfig.js +5 -2
- package/src/config/events.js +28 -0
- package/src/scss/main.scss +1 -0
- package/src/scss/settings/_dimensions.scss +0 -1
- package/src/utils/getSafeZoneInset.js +9 -7
- package/src/utils/getSafeZoneInset.test.js +10 -10
- package/webpack.dev.mjs +1 -1
- package/docs/api/slot-map.svg +0 -1
- package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
- package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
- package/plugins/beta/datasets/src/api/showDataset.js +0 -14
- package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
- package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
- package/plugins/beta/datasets/src/mapLayers.js +0 -164
- /package/src/{utils → services}/logger.js +0 -0
- /package/src/{utils → services}/logger.test.js +0 -0
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { handleSetMapStyle } from './handleSetMapStyle.js'
|
|
2
|
-
import { addMapLayers, getSourceId, getLayersUsingSource, isDynamicSource, updateSourceData } from './mapLayers.js'
|
|
3
1
|
import { createDynamicSource } from './fetch/createDynamicSource.js'
|
|
2
|
+
// NOSONAR: applyDatasetDefaults and datasetDefaults are used in processedDatasets.map
|
|
3
|
+
import { applyDatasetDefaults, datasetDefaults } from './defaults.js'
|
|
4
|
+
|
|
5
|
+
const isDynamicSource = (dataset) =>
|
|
6
|
+
typeof dataset.geojson === 'string' &&
|
|
7
|
+
!!dataset.idProperty &&
|
|
8
|
+
typeof dataset.transformRequest === 'function'
|
|
4
9
|
|
|
5
10
|
export const createDatasets = ({
|
|
11
|
+
adapter,
|
|
6
12
|
pluginConfig,
|
|
7
13
|
pluginStateRef,
|
|
8
14
|
mapStyleId,
|
|
@@ -10,72 +16,47 @@ export const createDatasets = ({
|
|
|
10
16
|
events,
|
|
11
17
|
eventBus
|
|
12
18
|
}) => {
|
|
13
|
-
const { map } = mapProvider
|
|
14
19
|
const { datasets } = pluginConfig
|
|
15
20
|
|
|
16
|
-
// Track dynamic sources for cleanup
|
|
17
21
|
const dynamicSources = new Map()
|
|
18
22
|
|
|
19
23
|
const getDatasets = () => pluginStateRef.current.datasets || datasets
|
|
20
24
|
const getHiddenFeatures = () => pluginStateRef.current.hiddenFeatures || {}
|
|
21
25
|
|
|
22
|
-
//
|
|
23
|
-
datasets.
|
|
24
|
-
|
|
26
|
+
// Initialise all datasets via the adapter, then set up dynamic sources
|
|
27
|
+
const processedDatasets = datasets.map(d => applyDatasetDefaults(d, datasetDefaults))
|
|
28
|
+
adapter.init(processedDatasets, mapStyleId).then(() => {
|
|
29
|
+
processedDatasets.forEach(dataset => {
|
|
30
|
+
if (!isDynamicSource(dataset)) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
25
33
|
|
|
26
|
-
// Initialize dynamic source if applicable
|
|
27
|
-
if (isDynamicSource(dataset)) {
|
|
28
|
-
const sourceId = getSourceId(dataset)
|
|
29
34
|
const dynamicSource = createDynamicSource({
|
|
30
35
|
dataset,
|
|
31
|
-
map,
|
|
32
|
-
|
|
33
|
-
onUpdate: (id, geojson) => updateSourceData(map, id, geojson)
|
|
36
|
+
map: mapProvider.map,
|
|
37
|
+
onUpdate: (datasetId, geojson) => adapter.setData(datasetId, geojson)
|
|
34
38
|
})
|
|
35
39
|
dynamicSources.set(dataset.id, dynamicSource)
|
|
36
|
-
}
|
|
37
|
-
})
|
|
40
|
+
})
|
|
38
41
|
|
|
39
|
-
// Emit ready event once map has processed the layers
|
|
40
|
-
map.once('idle', () => {
|
|
41
42
|
eventBus.emit('datasets:ready')
|
|
42
43
|
})
|
|
43
44
|
|
|
44
|
-
// Handle style changes
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
getHiddenFeatures,
|
|
51
|
-
getDynamicSources: () => dynamicSources
|
|
52
|
-
})
|
|
45
|
+
// Handle basemap style changes — delegate entirely to the adapter
|
|
46
|
+
const onSetStyle = (e) => {
|
|
47
|
+
adapter.onStyleChange(getDatasets(), e.id, getHiddenFeatures(), dynamicSources)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
eventBus.on(events.MAP_SET_STYLE, onSetStyle)
|
|
53
51
|
|
|
54
52
|
return {
|
|
55
53
|
remove () {
|
|
56
|
-
eventBus.off(events.MAP_SET_STYLE,
|
|
54
|
+
eventBus.off(events.MAP_SET_STYLE, onSetStyle)
|
|
57
55
|
|
|
58
56
|
// Clean up dynamic sources
|
|
59
57
|
dynamicSources.forEach(source => source.destroy())
|
|
60
58
|
dynamicSources.clear()
|
|
61
|
-
|
|
62
|
-
const allDatasets = getDatasets()
|
|
63
|
-
const removedSourceIds = new Set()
|
|
64
|
-
|
|
65
|
-
// Remove layers and sources
|
|
66
|
-
allDatasets.forEach(dataset => {
|
|
67
|
-
const sourceId = getSourceId(dataset)
|
|
68
|
-
const layers = getLayersUsingSource(map, sourceId)
|
|
69
|
-
|
|
70
|
-
// Remove all layers using this source
|
|
71
|
-
layers.forEach(id => map.removeLayer(id))
|
|
72
|
-
|
|
73
|
-
// Remove the source once
|
|
74
|
-
if (!removedSourceIds.has(sourceId) && map.getSource(sourceId)) {
|
|
75
|
-
map.removeSource(sourceId)
|
|
76
|
-
removedSourceIds.add(sourceId)
|
|
77
|
-
}
|
|
78
|
-
})
|
|
59
|
+
adapter.destroy(getDatasets())
|
|
79
60
|
},
|
|
80
61
|
|
|
81
62
|
/**
|
|
@@ -83,10 +64,7 @@ export const createDatasets = ({
|
|
|
83
64
|
* @param {string} datasetId - Dataset ID to refresh
|
|
84
65
|
*/
|
|
85
66
|
refreshDataset (datasetId) {
|
|
86
|
-
|
|
87
|
-
if (dynamicSource) {
|
|
88
|
-
dynamicSource.refresh()
|
|
89
|
-
}
|
|
67
|
+
dynamicSources.get(datasetId)?.refresh()
|
|
90
68
|
},
|
|
91
69
|
|
|
92
70
|
/**
|
|
@@ -94,10 +72,7 @@ export const createDatasets = ({
|
|
|
94
72
|
* @param {string} datasetId - Dataset ID to clear
|
|
95
73
|
*/
|
|
96
74
|
clearDatasetCache (datasetId) {
|
|
97
|
-
|
|
98
|
-
if (dynamicSource) {
|
|
99
|
-
dynamicSource.clear()
|
|
100
|
-
}
|
|
75
|
+
dynamicSources.get(datasetId)?.clear()
|
|
101
76
|
},
|
|
102
77
|
|
|
103
78
|
/**
|
|
@@ -106,8 +81,7 @@ export const createDatasets = ({
|
|
|
106
81
|
* @returns {number|null} Feature count or null if not a dynamic source
|
|
107
82
|
*/
|
|
108
83
|
getFeatureCount (datasetId) {
|
|
109
|
-
|
|
110
|
-
return dynamicSource ? dynamicSource.getFeatureCount() : null
|
|
84
|
+
return dynamicSources.get(datasetId)?.getFeatureCount() ?? null
|
|
111
85
|
}
|
|
112
86
|
}
|
|
113
87
|
}
|
|
@@ -1,15 +1,49 @@
|
|
|
1
1
|
const datasetDefaults = {
|
|
2
|
-
stroke: '#d4351c',
|
|
3
|
-
strokeWidth: 2,
|
|
4
|
-
fill: 'transparent',
|
|
5
|
-
symbolDescription: 'red outline',
|
|
6
2
|
minZoom: 6,
|
|
7
3
|
maxZoom: 24,
|
|
8
4
|
showInKey: false,
|
|
9
|
-
|
|
10
|
-
visibility: 'visible'
|
|
5
|
+
toggleVisibility: false,
|
|
6
|
+
visibility: 'visible',
|
|
7
|
+
style: {
|
|
8
|
+
stroke: '#d4351c',
|
|
9
|
+
strokeWidth: 2,
|
|
10
|
+
fill: 'transparent',
|
|
11
|
+
symbolDescription: 'red outline'
|
|
12
|
+
}
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
// All properties considered style properties — must be provided via dataset.style, not at the top level.
|
|
16
|
+
const STYLE_PROPS = [
|
|
17
|
+
'stroke', 'strokeWidth', 'strokeDashArray',
|
|
18
|
+
'fill', 'fillPattern', 'fillPatternSvgContent', 'fillPatternForegroundColor', 'fillPatternBackgroundColor',
|
|
19
|
+
'opacity', 'symbolDescription', 'keySymbolShape'
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
// Props whose presence in a style object indicates a custom visual style.
|
|
23
|
+
// When any are set, the default symbolDescription is not appropriate.
|
|
24
|
+
const VISUAL_STYLE_PROPS = ['stroke', 'fill', 'fillPattern', 'fillPatternSvgContent']
|
|
25
|
+
|
|
26
|
+
const hasCustomVisualStyle = (style) =>
|
|
27
|
+
VISUAL_STYLE_PROPS.some(prop => prop in style)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge a dataset config with defaults, flattening the nested `style` object.
|
|
31
|
+
* Style properties must be provided via dataset.style — top-level occurrences are ignored.
|
|
32
|
+
* symbolDescription from defaults.style is dropped when custom visual styles
|
|
33
|
+
* are present and the dataset doesn't explicitly set its own symbolDescription.
|
|
34
|
+
*/
|
|
35
|
+
const applyDatasetDefaults = (dataset, defaults) => {
|
|
36
|
+
const style = dataset.style || {}
|
|
37
|
+
const mergedStyle = { ...defaults.style, ...style }
|
|
38
|
+
if (!('symbolDescription' in style) && hasCustomVisualStyle(style)) {
|
|
39
|
+
delete mergedStyle.symbolDescription
|
|
40
|
+
}
|
|
41
|
+
const topLevel = { ...dataset }
|
|
42
|
+
delete topLevel.style
|
|
43
|
+
STYLE_PROPS.forEach(prop => delete topLevel[prop])
|
|
44
|
+
const topLevelDefaults = { ...defaults }
|
|
45
|
+
delete topLevelDefaults.style
|
|
46
|
+
return { ...topLevelDefaults, ...topLevel, ...mergedStyle }
|
|
15
47
|
}
|
|
48
|
+
|
|
49
|
+
export { datasetDefaults, hasCustomVisualStyle, applyDatasetDefaults }
|
|
@@ -14,17 +14,17 @@ const EVICTION_THRESHOLD = 1.2 // Trigger eviction at 120% of maxFeatures
|
|
|
14
14
|
* @param {Function} options.onUpdate - Callback when source data should be updated
|
|
15
15
|
* @returns {Object} { destroy, clear, refresh }
|
|
16
16
|
*/
|
|
17
|
-
export const createDynamicSource = ({ dataset, map,
|
|
17
|
+
export const createDynamicSource = ({ dataset, map, onUpdate }) => {
|
|
18
18
|
const { geojson: baseUrl, idProperty, transformRequest, maxFeatures, minZoom = 0 } = dataset
|
|
19
19
|
|
|
20
|
-
// Feature cache: id → { feature, bbox,
|
|
20
|
+
// Feature cache: id → { feature, bbox, lastSeenAt }
|
|
21
21
|
const features = new Map()
|
|
22
22
|
|
|
23
23
|
// Track the bbox we've fetched data for
|
|
24
24
|
let fetchedBbox = null
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
let
|
|
26
|
+
// Abort controller for the in-flight request
|
|
27
|
+
let currentController = null
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Convert features Map to FeatureCollection
|
|
@@ -63,25 +63,25 @@ export const createDynamicSource = ({ dataset, map, sourceId, onUpdate }) => {
|
|
|
63
63
|
if (bboxIntersects(data.bbox, currentBbox)) {
|
|
64
64
|
inView.push(id)
|
|
65
65
|
} else {
|
|
66
|
-
outOfView.push({ id,
|
|
66
|
+
outOfView.push({ id, lastSeenAt: data.lastSeenAt })
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Sort out-of-view by
|
|
71
|
-
outOfView.sort((a, b) => a.
|
|
70
|
+
// Sort out-of-view by last seen time (least recently seen first)
|
|
71
|
+
outOfView.sort((a, b) => a.lastSeenAt - b.lastSeenAt)
|
|
72
72
|
|
|
73
|
-
// Evict
|
|
73
|
+
// Evict least-recently-seen out-of-view features until under target
|
|
74
74
|
const toEvict = features.size - targetSize
|
|
75
75
|
for (let i = 0; i < toEvict && i < outOfView.length; i++) {
|
|
76
76
|
features.delete(outOfView[i].id)
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
// If still over target (viewport has too many), evict
|
|
79
|
+
// If still over target (viewport has too many), evict least recently seen in-view
|
|
80
80
|
if (features.size > targetSize) {
|
|
81
81
|
const remaining = features.size - targetSize
|
|
82
82
|
const inViewSorted = inView
|
|
83
|
-
.map(id => ({ id,
|
|
84
|
-
.sort((a, b) => a.
|
|
83
|
+
.map(id => ({ id, lastSeenAt: features.get(id).lastSeenAt }))
|
|
84
|
+
.sort((a, b) => a.lastSeenAt - b.lastSeenAt)
|
|
85
85
|
|
|
86
86
|
for (let i = 0; i < remaining && i < inViewSorted.length; i++) {
|
|
87
87
|
features.delete(inViewSorted[i].id)
|
|
@@ -105,19 +105,19 @@ export const createDynamicSource = ({ dataset, map, sourceId, onUpdate }) => {
|
|
|
105
105
|
return
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
// Abort any in-flight request — new viewport takes priority
|
|
109
|
+
if (currentController) {
|
|
110
|
+
currentController.abort()
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
isLoading = true
|
|
112
|
+
currentController = new AbortController()
|
|
113
113
|
|
|
114
114
|
try {
|
|
115
115
|
const context = { bbox: currentBbox, zoom, dataset }
|
|
116
|
-
const data = await fetchGeoJSON(baseUrl, context, transformRequest)
|
|
116
|
+
const data = await fetchGeoJSON(baseUrl, context, transformRequest, currentController.signal)
|
|
117
117
|
|
|
118
118
|
const now = Date.now()
|
|
119
119
|
|
|
120
|
-
// Add/update features with deduplication
|
|
120
|
+
// Add/update features with deduplication, refreshing lastSeenAt on each fetch
|
|
121
121
|
data.features.forEach(feature => {
|
|
122
122
|
const id = getFeatureId(feature)
|
|
123
123
|
if (id == null) {
|
|
@@ -128,22 +128,28 @@ export const createDynamicSource = ({ dataset, map, sourceId, onUpdate }) => {
|
|
|
128
128
|
features.set(id, {
|
|
129
129
|
feature,
|
|
130
130
|
bbox: getGeometryBbox(feature.geometry),
|
|
131
|
-
|
|
131
|
+
lastSeenAt: now
|
|
132
132
|
})
|
|
133
133
|
})
|
|
134
134
|
|
|
135
135
|
// Expand tracked bbox
|
|
136
136
|
fetchedBbox = expandBbox(fetchedBbox, currentBbox)
|
|
137
137
|
|
|
138
|
-
// Evict if over limit
|
|
138
|
+
// Evict if over limit; if features were removed, fetchedBbox no longer
|
|
139
|
+
// covers those regions — reset to current viewport to force re-fetch on return
|
|
140
|
+
const sizeBeforeEviction = features.size
|
|
139
141
|
evictIfNeeded(currentBbox)
|
|
142
|
+
if (features.size < sizeBeforeEviction) {
|
|
143
|
+
fetchedBbox = currentBbox
|
|
144
|
+
}
|
|
140
145
|
|
|
141
146
|
// Update map source
|
|
142
|
-
onUpdate(
|
|
147
|
+
onUpdate(dataset.id, toFeatureCollection())
|
|
143
148
|
} catch (error) {
|
|
149
|
+
if (error.name === 'AbortError') {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
144
152
|
console.error(`Failed to fetch dynamic GeoJSON for ${dataset.id}:`, error)
|
|
145
|
-
} finally {
|
|
146
|
-
isLoading = false
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
|
|
@@ -162,11 +168,14 @@ export const createDynamicSource = ({ dataset, map, sourceId, onUpdate }) => {
|
|
|
162
168
|
|
|
163
169
|
return {
|
|
164
170
|
/**
|
|
165
|
-
* Clean up event listeners
|
|
171
|
+
* Clean up event listeners and cancel any in-flight request
|
|
166
172
|
*/
|
|
167
173
|
destroy () {
|
|
168
174
|
map.off('moveend', handleMoveEnd)
|
|
169
175
|
debouncedFetch.cancel()
|
|
176
|
+
if (currentController) {
|
|
177
|
+
currentController.abort()
|
|
178
|
+
}
|
|
170
179
|
},
|
|
171
180
|
|
|
172
181
|
/**
|
|
@@ -175,7 +184,7 @@ export const createDynamicSource = ({ dataset, map, sourceId, onUpdate }) => {
|
|
|
175
184
|
clear () {
|
|
176
185
|
features.clear()
|
|
177
186
|
fetchedBbox = null
|
|
178
|
-
onUpdate(
|
|
187
|
+
onUpdate(dataset.id, { type: 'FeatureCollection', features: [] })
|
|
179
188
|
},
|
|
180
189
|
|
|
181
190
|
/**
|
|
@@ -199,7 +208,7 @@ export const createDynamicSource = ({ dataset, map, sourceId, onUpdate }) => {
|
|
|
199
208
|
*/
|
|
200
209
|
reapply () {
|
|
201
210
|
if (features.size > 0) {
|
|
202
|
-
onUpdate(
|
|
211
|
+
onUpdate(dataset.id, toFeatureCollection())
|
|
203
212
|
}
|
|
204
213
|
}
|
|
205
214
|
}
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* @param {Function} transformRequest - Function to transform the request (builds URL with bbox, adds headers)
|
|
6
6
|
* @returns {Promise<Object>} GeoJSON FeatureCollection
|
|
7
7
|
*/
|
|
8
|
-
export const fetchGeoJSON = async (baseUrl, context, transformRequest) => {
|
|
8
|
+
export const fetchGeoJSON = async (baseUrl, context, transformRequest, signal) => {
|
|
9
9
|
const result = transformRequest(baseUrl, context)
|
|
10
10
|
|
|
11
11
|
// Handle both string and object return values
|
|
12
12
|
const config = typeof result === 'string' ? { url: result } : result
|
|
13
13
|
const { url, headers = {} } = config
|
|
14
14
|
|
|
15
|
-
const response = await fetch(url, { headers })
|
|
15
|
+
const response = await fetch(url, { headers, signal })
|
|
16
16
|
|
|
17
17
|
if (!response.ok) {
|
|
18
18
|
throw new Error(`Failed to fetch GeoJSON: ${response.status} ${response.statusText}`)
|
|
@@ -3,12 +3,15 @@ import { initialState, actions } from './reducer.js'
|
|
|
3
3
|
import { DatasetsInit } from './DatasetsInit.jsx'
|
|
4
4
|
import { Layers } from './panels/Layers.jsx'
|
|
5
5
|
import { Key } from './panels/Key.jsx'
|
|
6
|
-
import { showDataset } from './api/showDataset.js'
|
|
7
|
-
import { hideDataset } from './api/hideDataset.js'
|
|
8
6
|
import { addDataset } from './api/addDataset.js'
|
|
9
7
|
import { removeDataset } from './api/removeDataset.js'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
8
|
+
import { setDatasetVisibility } from './api/setDatasetVisibility.js'
|
|
9
|
+
import { setFeatureVisibility } from './api/setFeatureVisibility.js'
|
|
10
|
+
import { setStyle } from './api/setStyle.js'
|
|
11
|
+
import { getStyle } from './api/getStyle.js'
|
|
12
|
+
import { setOpacity } from './api/setOpacity.js'
|
|
13
|
+
import { getOpacity } from './api/getOpacity.js'
|
|
14
|
+
import { setData } from './api/setData.js'
|
|
12
15
|
|
|
13
16
|
export const manifest = {
|
|
14
17
|
InitComponent: DatasetsInit,
|
|
@@ -22,7 +25,7 @@ export const manifest = {
|
|
|
22
25
|
id: 'datasetsLayers',
|
|
23
26
|
label: 'Layers',
|
|
24
27
|
mobile: {
|
|
25
|
-
slot: '
|
|
28
|
+
slot: 'drawer',
|
|
26
29
|
modal: true,
|
|
27
30
|
dismissible: true
|
|
28
31
|
},
|
|
@@ -30,30 +33,30 @@ export const manifest = {
|
|
|
30
33
|
slot: 'left-top',
|
|
31
34
|
dismissible: true,
|
|
32
35
|
exclusive: true,
|
|
33
|
-
width: '
|
|
36
|
+
width: '260px'
|
|
34
37
|
},
|
|
35
38
|
desktop: {
|
|
36
39
|
slot: 'left-top',
|
|
37
40
|
modal: false,
|
|
38
41
|
dismissible: true,
|
|
39
42
|
exclusive: true,
|
|
40
|
-
width: '
|
|
43
|
+
width: '280px'
|
|
41
44
|
},
|
|
42
45
|
render: Layers
|
|
43
46
|
}, {
|
|
44
47
|
id: 'datasetsKey',
|
|
45
48
|
label: 'Key',
|
|
46
49
|
mobile: {
|
|
47
|
-
slot: '
|
|
50
|
+
slot: 'drawer',
|
|
48
51
|
modal: true
|
|
49
52
|
},
|
|
50
53
|
tablet: {
|
|
51
54
|
slot: 'left-top',
|
|
52
|
-
width: '
|
|
55
|
+
width: '260px'
|
|
53
56
|
},
|
|
54
57
|
desktop: {
|
|
55
58
|
slot: 'left-top',
|
|
56
|
-
width: '
|
|
59
|
+
width: '280px'
|
|
57
60
|
},
|
|
58
61
|
render: Key
|
|
59
62
|
}],
|
|
@@ -63,7 +66,9 @@ export const manifest = {
|
|
|
63
66
|
label: 'Layers',
|
|
64
67
|
panelId: 'datasetsLayers',
|
|
65
68
|
iconId: 'layers',
|
|
66
|
-
excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.
|
|
69
|
+
excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l =>
|
|
70
|
+
l.toggleVisibility || l.sublayers?.some(r => r.toggleVisibility)
|
|
71
|
+
),
|
|
67
72
|
mobile: {
|
|
68
73
|
slot: 'top-left',
|
|
69
74
|
showLabel: true
|
|
@@ -81,7 +86,7 @@ export const manifest = {
|
|
|
81
86
|
label: 'Key',
|
|
82
87
|
panelId: 'datasetsKey',
|
|
83
88
|
iconId: 'key',
|
|
84
|
-
excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.
|
|
89
|
+
excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l => l.showInKey),
|
|
85
90
|
mobile: {
|
|
86
91
|
slot: 'top-left',
|
|
87
92
|
showLabel: false
|
|
@@ -105,11 +110,14 @@ export const manifest = {
|
|
|
105
110
|
}],
|
|
106
111
|
|
|
107
112
|
api: {
|
|
108
|
-
showDataset,
|
|
109
|
-
hideDataset,
|
|
110
113
|
addDataset,
|
|
111
114
|
removeDataset,
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
setDatasetVisibility,
|
|
116
|
+
setFeatureVisibility,
|
|
117
|
+
setStyle,
|
|
118
|
+
getStyle,
|
|
119
|
+
setOpacity,
|
|
120
|
+
getOpacity,
|
|
121
|
+
setData
|
|
114
122
|
}
|
|
115
123
|
}
|
|
@@ -1,62 +1,140 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { getValueForStyle } from '../../../../../src/utils/getValueForStyle'
|
|
3
|
+
import { hasPattern, getKeyPatternPaths } from '../styles/patterns.js'
|
|
4
|
+
import { mergeSublayer } from '../utils/mergeSublayer.js'
|
|
5
|
+
|
|
6
|
+
const SVG_SIZE = 20
|
|
7
|
+
const SVG_CENTER = SVG_SIZE / 2
|
|
8
|
+
const PATTERN_INSET = 2
|
|
9
|
+
|
|
10
|
+
const buildKeyGroups = (datasets) => {
|
|
11
|
+
const seenGroups = new Set()
|
|
12
|
+
const items = []
|
|
13
|
+
datasets.forEach(dataset => {
|
|
14
|
+
if (dataset.sublayers?.length) {
|
|
15
|
+
items.push({ type: 'sublayers', dataset })
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
if (dataset.groupLabel) {
|
|
19
|
+
if (seenGroups.has(dataset.groupLabel)) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
seenGroups.add(dataset.groupLabel)
|
|
23
|
+
items.push({
|
|
24
|
+
type: 'group',
|
|
25
|
+
groupLabel: dataset.groupLabel,
|
|
26
|
+
datasets: datasets.filter(d => !d.sublayers?.length && d.groupLabel === dataset.groupLabel)
|
|
27
|
+
})
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
items.push({ type: 'flat', dataset })
|
|
31
|
+
})
|
|
32
|
+
return items
|
|
33
|
+
}
|
|
3
34
|
|
|
4
35
|
export const Key = ({ mapState, pluginState }) => {
|
|
5
36
|
const { mapStyle } = mapState
|
|
6
37
|
|
|
7
|
-
const itemSymbol = (
|
|
8
|
-
|
|
9
|
-
xmlns
|
|
10
|
-
width
|
|
11
|
-
height
|
|
12
|
-
viewBox
|
|
13
|
-
aria-hidden
|
|
14
|
-
focusable
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
const itemSymbol = (config) => {
|
|
39
|
+
const svgProps = {
|
|
40
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
41
|
+
width: SVG_SIZE,
|
|
42
|
+
height: SVG_SIZE,
|
|
43
|
+
viewBox: `0 0 ${SVG_SIZE} ${SVG_SIZE}`,
|
|
44
|
+
'aria-hidden': 'true',
|
|
45
|
+
focusable: 'false'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (hasPattern(config)) {
|
|
49
|
+
const paths = getKeyPatternPaths(config, mapStyle.id)
|
|
50
|
+
return (
|
|
51
|
+
<svg {...svgProps}>
|
|
52
|
+
<g dangerouslySetInnerHTML={{ __html: paths.border }} />
|
|
53
|
+
<g transform={`translate(${PATTERN_INSET}, ${PATTERN_INSET})`} dangerouslySetInnerHTML={{ __html: paths.content }} />
|
|
54
|
+
</svg>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<svg {...svgProps}>
|
|
60
|
+
{config.keySymbolShape === 'line'
|
|
61
|
+
? (
|
|
62
|
+
<line
|
|
63
|
+
x1={config.strokeWidth / 2}
|
|
64
|
+
y1={SVG_CENTER}
|
|
65
|
+
x2={SVG_SIZE - config.strokeWidth / 2}
|
|
66
|
+
y2={SVG_CENTER}
|
|
67
|
+
stroke={getValueForStyle(config.stroke, mapStyle.id)}
|
|
68
|
+
strokeWidth={config.strokeWidth}
|
|
69
|
+
strokeLinecap='round'
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
: (
|
|
73
|
+
<rect
|
|
74
|
+
x={config.strokeWidth / 2}
|
|
75
|
+
y={config.strokeWidth / 2}
|
|
76
|
+
width={SVG_SIZE - config.strokeWidth}
|
|
77
|
+
height={SVG_SIZE - config.strokeWidth}
|
|
78
|
+
rx={config.strokeWidth}
|
|
79
|
+
ry={config.strokeWidth}
|
|
80
|
+
fill={getValueForStyle(config.fill, mapStyle.id)}
|
|
81
|
+
stroke={getValueForStyle(config.stroke, mapStyle.id)}
|
|
82
|
+
strokeWidth={config.strokeWidth}
|
|
83
|
+
strokeLinejoin='round'
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</svg>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const renderEntry = (key, config) => (
|
|
91
|
+
<dl key={key} className='im-c-datasets-key__item'>
|
|
92
|
+
<dt className='im-c-datasets-key__item-symbol'>{itemSymbol(config)}</dt>
|
|
93
|
+
<dd className='im-c-datasets-key__item-label'>
|
|
94
|
+
{config.label}
|
|
95
|
+
{config.symbolDescription && (
|
|
96
|
+
<span className='govuk-visually-hidden'>
|
|
97
|
+
({getValueForStyle(config.symbolDescription, mapStyle.id)})
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</dd>
|
|
101
|
+
</dl>
|
|
43
102
|
)
|
|
44
103
|
|
|
104
|
+
const visibleDatasets = (pluginState.datasets || [])
|
|
105
|
+
.filter(dataset => dataset.showInKey && dataset.visibility !== 'hidden')
|
|
106
|
+
|
|
107
|
+
const keyGroups = buildKeyGroups(visibleDatasets)
|
|
108
|
+
const hasGroups = keyGroups.some(item => item.type === 'sublayers' || item.type === 'group')
|
|
109
|
+
const containerClass = `im-c-datasets-key${hasGroups ? ' im-c-datasets-key--has-groups' : ''}`
|
|
110
|
+
|
|
45
111
|
return (
|
|
46
|
-
<div className=
|
|
47
|
-
{
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{dataset.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
112
|
+
<div className={containerClass}>
|
|
113
|
+
{keyGroups.map(item => {
|
|
114
|
+
if (item.type === 'sublayers') {
|
|
115
|
+
const headingId = `key-heading-${item.dataset.id}`
|
|
116
|
+
return (
|
|
117
|
+
<section key={item.dataset.id} className='im-c-datasets-key__group' aria-labelledby={headingId}>
|
|
118
|
+
<h3 id={headingId} className='im-c-datasets-key__group-heading'>{item.dataset.label}</h3>
|
|
119
|
+
{item.dataset.sublayers
|
|
120
|
+
.filter(sublayer => item.dataset.sublayerVisibility?.[sublayer.id] !== 'hidden')
|
|
121
|
+
.map(sublayer => renderEntry(`${item.dataset.id}-${sublayer.id}`, mergeSublayer(item.dataset, sublayer)))}
|
|
122
|
+
</section>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (item.type === 'group') {
|
|
127
|
+
const headingId = `key-heading-${item.groupLabel.toLowerCase().replaceAll(/\s+/g, '-')}`
|
|
128
|
+
return (
|
|
129
|
+
<section key={item.groupLabel} className='im-c-datasets-key__group' aria-labelledby={headingId}>
|
|
130
|
+
<h3 id={headingId} className='im-c-datasets-key__group-heading'>{item.groupLabel}</h3>
|
|
131
|
+
{item.datasets.map(dataset => renderEntry(dataset.id, dataset))}
|
|
132
|
+
</section>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return renderEntry(item.dataset.id, item.dataset)
|
|
137
|
+
})}
|
|
60
138
|
</div>
|
|
61
139
|
)
|
|
62
140
|
}
|