@defra/interactive-map 0.0.4-alpha → 0.0.6-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/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api.md +8 -0
- package/package.json +3 -2
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
- package/plugins/beta/datasets/dist/umd/index.js +1 -1
- package/plugins/beta/datasets/src/datasets.js +55 -2
- package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
- package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
- package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
- package/plugins/beta/datasets/src/mapLayers.js +41 -3
- package/plugins/beta/datasets/src/utils/bbox.js +113 -2
- 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/dist/umd/index.js +1 -1
- package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
- package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
- package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
- package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
- package/plugins/beta/draw-ml/src/defaults.js +1 -0
- package/plugins/beta/draw-ml/src/draw.scss +23 -0
- package/plugins/beta/draw-ml/src/events.js +55 -113
- package/plugins/beta/draw-ml/src/manifest.js +64 -38
- package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
- package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
- package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
- 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 +1 -1
- package/plugins/beta/map-styles/dist/css/index.css +1 -1
- package/plugins/beta/map-styles/dist/esm/index.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/map-styles/src/mapStyles.scss +4 -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/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +1 -1
- package/plugins/interact/src/defaults.js +1 -0
- package/plugins/interact/src/events.js +37 -52
- package/plugins/interact/src/events.test.js +35 -0
- package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
- package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
- package/plugins/interact/src/interact.scss +6 -0
- package/plugins/interact/src/reducer.js +25 -27
- package/plugins/interact/src/reducer.test.js +19 -6
- package/plugins/interact/src/utils/featureQueries.js +2 -2
- package/plugins/interact/src/utils/spatial.js +99 -0
- package/plugins/interact/src/utils/spatial.test.js +100 -0
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +2 -2
- package/plugins/search/src/components/Form/Form.jsx +1 -1
- package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
- package/plugins/search/src/datasets.js +1 -1
- package/plugins/search/src/defaults.js +3 -0
- package/plugins/search/src/events/fetchSuggestions.js +48 -53
- package/plugins/search/src/events/formHandlers.js +3 -2
- package/plugins/search/src/events/index.js +1 -1
- package/plugins/search/src/events/inputHandlers.js +3 -1
- package/plugins/search/src/events/suggestionHandlers.js +12 -3
- package/plugins/search/src/search.scss +4 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
- package/providers/beta/esri/src/esriProvider.js +1 -1
- package/providers/beta/esri/src/index.js +1 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/defaults.js +3 -2
- package/providers/maplibre/src/index.js +22 -19
- package/providers/maplibre/src/maplibreProvider.js +13 -11
- package/providers/maplibre/src/utils/detectWebgl.js +1 -2
- package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
- package/providers/maplibre/src/utils/labels.js +174 -120
- package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
- package/providers/maplibre/src/utils/queryFeatures.js +146 -0
- package/providers/maplibre/src/utils/spatial.js +14 -17
- package/sonar-project.properties +39 -2
- package/src/App/components/Actions/Actions.jsx +2 -2
- package/src/App/components/Actions/Actions.module.scss +2 -2
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
- package/src/App/components/Logo/Logo.jsx +1 -1
- package/src/App/components/MapButton/MapButton.jsx +217 -41
- package/src/App/components/MapButton/MapButton.module.scss +32 -21
- package/src/App/components/MapButton/MapButton.test.jsx +128 -84
- package/src/App/components/Markers/Markers.jsx +1 -1
- package/src/App/components/Markers/Markers.module.scss +0 -5
- package/src/App/components/Panel/Panel.jsx +75 -45
- package/src/App/components/Panel/Panel.module.scss +18 -24
- package/src/App/components/Panel/Panel.test.jsx +18 -0
- package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
- package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
- package/src/App/components/Tooltip/Tooltip.jsx +4 -2
- package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
- package/src/App/components/Viewport/MapController.jsx +1 -1
- package/src/App/components/Viewport/MapStatus.jsx +1 -1
- package/src/App/components/Viewport/Viewport.jsx +2 -2
- package/src/App/components/Viewport/Viewport.module.scss +2 -2
- package/src/App/controls/keyboardActions.js +3 -2
- package/src/App/controls/keyboardShortcuts.js +4 -2
- package/src/App/hooks/useButtonStateEvaluator.js +58 -45
- package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
- package/src/App/hooks/useCrossHairAPI.js +49 -47
- package/src/App/hooks/useFocusVisible.js +2 -2
- package/src/App/hooks/useInterfaceAPI.js +16 -4
- package/src/App/hooks/useKeyboardHint.js +1 -1
- package/src/App/hooks/useKeyboardShortcuts.js +4 -2
- package/src/App/hooks/useMapAnnouncements.js +34 -32
- package/src/App/hooks/useMapEvents.js +12 -1
- package/src/App/hooks/useMapProviderOverrides.js +3 -3
- package/src/App/hooks/useMapStateSync.js +7 -1
- package/src/App/hooks/useMapURLSync.js +6 -1
- package/src/App/hooks/useMarkersAPI.js +88 -50
- package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
- package/src/App/hooks/useModalPanelBehaviour.js +46 -40
- package/src/App/hooks/useResizeObserver.js +2 -2
- package/src/App/initialiseApp.js +45 -38
- package/src/App/layout/Layout.jsx +6 -4
- package/src/App/layout/layout.module.scss +13 -17
- package/src/App/registry/keyboardShortcutRegistry.js +12 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
- package/src/App/registry/mergeManifests.js +1 -1
- package/src/App/registry/panelRegistry.js +1 -1
- package/src/App/registry/panelRegistry.test.js +30 -1
- package/src/App/registry/pluginRegistry.js +14 -51
- package/src/App/registry/pluginRegistry.test.js +54 -6
- package/src/App/renderer/HtmlElementHost.jsx +186 -0
- package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
- package/src/App/renderer/mapButtons.js +11 -8
- package/src/App/renderer/mapButtons.test.js +6 -4
- package/src/App/renderer/mapControls.js +6 -0
- package/src/App/renderer/mapControls.test.js +10 -2
- package/src/App/renderer/mapPanels.js +39 -26
- package/src/App/renderer/mapPanels.test.js +29 -0
- package/src/App/renderer/pluginWrapper.test.js +28 -0
- package/src/App/renderer/slotHelpers.js +60 -0
- package/src/App/renderer/slotHelpers.test.js +82 -0
- package/src/App/store/AppProvider.jsx +21 -18
- package/src/App/store/AppProvider.test.jsx +40 -0
- package/src/App/store/PluginProvider.jsx +7 -5
- package/src/App/store/appActionsMap.js +28 -1
- package/src/App/store/appActionsMap.test.js +26 -0
- package/src/App/store/appReducer.js +1 -0
- package/src/App/store/mapActionsMap.js +12 -24
- package/src/App/store/mapReducer.js +1 -1
- package/src/InteractiveMap/InteractiveMap.js +79 -6
- package/src/InteractiveMap/InteractiveMap.test.js +164 -2
- package/src/InteractiveMap/behaviourController.js +12 -2
- package/src/InteractiveMap/behaviourController.test.js +82 -1
- package/src/InteractiveMap/deviceChecker.js +1 -1
- package/src/InteractiveMap/deviceChecker.test.js +2 -2
- package/src/InteractiveMap/historyManager.js +41 -5
- package/src/InteractiveMap/historyManager.test.js +37 -6
- package/src/InteractiveMap/polyfills.js +39 -0
- package/src/InteractiveMap/polyfills.test.js +127 -0
- package/src/config/appConfig.js +5 -5
- package/src/config/appConfig.test.js +107 -42
- package/src/config/defaults.js +1 -0
- package/src/index.umd.js +1 -1
- package/src/scss/main.scss +1 -0
- package/src/scss/settings/_colors.scss +1 -1
- package/src/scss/settings/_dimensions.scss +4 -0
- package/src/services/eventBus.test.js +24 -1
- package/src/test-utils.js +1 -0
- package/src/types.js +5 -0
- package/src/utils/detectBreakpoint.js +78 -77
- package/src/utils/detectBreakpoint.test.js +50 -4
- package/src/utils/detectInterfaceType.js +6 -5
- package/src/utils/getIsFullscreen.js +3 -1
- package/src/utils/getSafeZoneInset.js +23 -7
- package/src/utils/getSafeZoneInset.test.js +18 -0
- package/src/utils/mapStateSync.js +3 -3
- package/src/utils/queryString.js +1 -1
- package/src/utils/stringToKebab.js +1 -1
- package/src/utils/toggleInertElements.js +37 -12
- package/webpack.dev.mjs +3 -4
- package/plugins/interact/src/utils/turfHelpers.js +0 -30
- package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
- package/plugins/search/src/utils/fetchDataset.js +0 -23
- package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
- package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { handleSetMapStyle } from './handleSetMapStyle.js'
|
|
2
|
-
import { addMapLayers, getSourceId, getLayersUsingSource } from './mapLayers.js'
|
|
2
|
+
import { addMapLayers, getSourceId, getLayersUsingSource, isDynamicSource, updateSourceData } from './mapLayers.js'
|
|
3
|
+
import { createDynamicSource } from './fetch/createDynamicSource.js'
|
|
3
4
|
|
|
4
5
|
export const createDatasets = ({
|
|
5
6
|
pluginConfig,
|
|
@@ -12,12 +13,27 @@ export const createDatasets = ({
|
|
|
12
13
|
const { map } = mapProvider
|
|
13
14
|
const { datasets } = pluginConfig
|
|
14
15
|
|
|
16
|
+
// Track dynamic sources for cleanup
|
|
17
|
+
const dynamicSources = new Map()
|
|
18
|
+
|
|
15
19
|
const getDatasets = () => pluginStateRef.current.datasets || datasets
|
|
16
20
|
const getHiddenFeatures = () => pluginStateRef.current.hiddenFeatures || {}
|
|
17
21
|
|
|
18
22
|
// Initialize all datasets once
|
|
19
23
|
datasets.forEach(dataset => {
|
|
20
24
|
addMapLayers(map, mapStyleId, dataset)
|
|
25
|
+
|
|
26
|
+
// Initialize dynamic source if applicable
|
|
27
|
+
if (isDynamicSource(dataset)) {
|
|
28
|
+
const sourceId = getSourceId(dataset)
|
|
29
|
+
const dynamicSource = createDynamicSource({
|
|
30
|
+
dataset,
|
|
31
|
+
map,
|
|
32
|
+
sourceId,
|
|
33
|
+
onUpdate: (id, geojson) => updateSourceData(map, id, geojson)
|
|
34
|
+
})
|
|
35
|
+
dynamicSources.set(dataset.id, dynamicSource)
|
|
36
|
+
}
|
|
21
37
|
})
|
|
22
38
|
|
|
23
39
|
// Emit ready event once map has processed the layers
|
|
@@ -31,13 +47,18 @@ export const createDatasets = ({
|
|
|
31
47
|
events,
|
|
32
48
|
eventBus,
|
|
33
49
|
getDatasets,
|
|
34
|
-
getHiddenFeatures
|
|
50
|
+
getHiddenFeatures,
|
|
51
|
+
getDynamicSources: () => dynamicSources
|
|
35
52
|
})
|
|
36
53
|
|
|
37
54
|
return {
|
|
38
55
|
remove() {
|
|
39
56
|
eventBus.off(events.MAP_SET_STYLE, styleHandler)
|
|
40
57
|
|
|
58
|
+
// Clean up dynamic sources
|
|
59
|
+
dynamicSources.forEach(source => source.destroy())
|
|
60
|
+
dynamicSources.clear()
|
|
61
|
+
|
|
41
62
|
const allDatasets = getDatasets()
|
|
42
63
|
const removedSourceIds = new Set()
|
|
43
64
|
|
|
@@ -55,6 +76,38 @@ export const createDatasets = ({
|
|
|
55
76
|
removedSourceIds.add(sourceId)
|
|
56
77
|
}
|
|
57
78
|
})
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Refresh a dynamic source - clears cache and re-fetches
|
|
83
|
+
* @param {string} datasetId - Dataset ID to refresh
|
|
84
|
+
*/
|
|
85
|
+
refreshDataset(datasetId) {
|
|
86
|
+
const dynamicSource = dynamicSources.get(datasetId)
|
|
87
|
+
if (dynamicSource) {
|
|
88
|
+
dynamicSource.refresh()
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Clear a dynamic source's cache
|
|
94
|
+
* @param {string} datasetId - Dataset ID to clear
|
|
95
|
+
*/
|
|
96
|
+
clearDatasetCache(datasetId) {
|
|
97
|
+
const dynamicSource = dynamicSources.get(datasetId)
|
|
98
|
+
if (dynamicSource) {
|
|
99
|
+
dynamicSource.clear()
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get feature count for a dynamic source
|
|
105
|
+
* @param {string} datasetId - Dataset ID
|
|
106
|
+
* @returns {number|null} Feature count or null if not a dynamic source
|
|
107
|
+
*/
|
|
108
|
+
getFeatureCount(datasetId) {
|
|
109
|
+
const dynamicSource = dynamicSources.get(datasetId)
|
|
110
|
+
return dynamicSource ? dynamicSource.getFeatureCount() : null
|
|
58
111
|
}
|
|
59
112
|
}
|
|
60
113
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { fetchGeoJSON } from './fetchGeoJSON.js'
|
|
2
|
+
import { getBboxArray, bboxContains, expandBbox, bboxIntersects, getGeometryBbox } from '../utils/bbox.js'
|
|
3
|
+
import { debounce } from '../utils/debounce.js'
|
|
4
|
+
|
|
5
|
+
const DEBOUNCE_DELAY = 200
|
|
6
|
+
const EVICTION_THRESHOLD = 1.2 // Trigger eviction at 120% of maxFeatures
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a dynamic GeoJSON source that fetches data based on viewport
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {Object} options.dataset - Dataset configuration
|
|
12
|
+
* @param {Object} options.map - Map instance
|
|
13
|
+
* @param {string} options.sourceId - Source ID for the map
|
|
14
|
+
* @param {Function} options.onUpdate - Callback when source data should be updated
|
|
15
|
+
* @returns {Object} { destroy, clear, refresh }
|
|
16
|
+
*/
|
|
17
|
+
export const createDynamicSource = ({ dataset, map, sourceId, onUpdate }) => {
|
|
18
|
+
const { geojson: baseUrl, idProperty, transformRequest, maxFeatures, minZoom = 0 } = dataset
|
|
19
|
+
|
|
20
|
+
// Feature cache: id → { feature, bbox, addedAt }
|
|
21
|
+
const features = new Map()
|
|
22
|
+
|
|
23
|
+
// Track the bbox we've fetched data for
|
|
24
|
+
let fetchedBbox = null
|
|
25
|
+
|
|
26
|
+
// Loading state
|
|
27
|
+
let isLoading = false
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert features Map to FeatureCollection
|
|
31
|
+
*/
|
|
32
|
+
const toFeatureCollection = () => ({
|
|
33
|
+
type: 'FeatureCollection',
|
|
34
|
+
features: Array.from(features.values()).map(entry => entry.feature)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get feature ID from a feature
|
|
39
|
+
*/
|
|
40
|
+
const getFeatureId = (feature) => {
|
|
41
|
+
if (idProperty) {
|
|
42
|
+
return feature.properties?.[idProperty] ?? feature.id
|
|
43
|
+
}
|
|
44
|
+
return feature.id
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Evict features that are outside the current viewport
|
|
49
|
+
* Uses "least recently visible" strategy - evicts features furthest from viewport first
|
|
50
|
+
*/
|
|
51
|
+
const evictIfNeeded = (currentBbox) => {
|
|
52
|
+
if (!maxFeatures || features.size <= maxFeatures * EVICTION_THRESHOLD) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const targetSize = maxFeatures
|
|
57
|
+
|
|
58
|
+
// Partition features: in-viewport vs out-of-viewport
|
|
59
|
+
const inView = []
|
|
60
|
+
const outOfView = []
|
|
61
|
+
|
|
62
|
+
for (const [id, data] of features) {
|
|
63
|
+
if (bboxIntersects(data.bbox, currentBbox)) {
|
|
64
|
+
inView.push(id)
|
|
65
|
+
} else {
|
|
66
|
+
outOfView.push({ id, addedAt: data.addedAt })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Sort out-of-view by insertion time (oldest first)
|
|
71
|
+
outOfView.sort((a, b) => a.addedAt - b.addedAt)
|
|
72
|
+
|
|
73
|
+
// Evict oldest out-of-view features until under target
|
|
74
|
+
const toEvict = features.size - targetSize
|
|
75
|
+
for (let i = 0; i < toEvict && i < outOfView.length; i++) {
|
|
76
|
+
features.delete(outOfView[i].id)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// If still over target (viewport has too many), evict oldest in-view
|
|
80
|
+
if (features.size > targetSize) {
|
|
81
|
+
const remaining = features.size - targetSize
|
|
82
|
+
const inViewSorted = inView
|
|
83
|
+
.map(id => ({ id, addedAt: features.get(id).addedAt }))
|
|
84
|
+
.sort((a, b) => a.addedAt - b.addedAt)
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < remaining && i < inViewSorted.length; i++) {
|
|
87
|
+
features.delete(inViewSorted[i].id)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fetch data for the current viewport
|
|
94
|
+
*/
|
|
95
|
+
const fetchData = async () => {
|
|
96
|
+
const zoom = map.getZoom()
|
|
97
|
+
if (zoom < minZoom) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const currentBbox = getBboxArray(map)
|
|
102
|
+
|
|
103
|
+
// Skip if current viewport is already covered
|
|
104
|
+
if (fetchedBbox && bboxContains(fetchedBbox, currentBbox)) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (isLoading) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
isLoading = true
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const context = { bbox: currentBbox, zoom, dataset }
|
|
116
|
+
const data = await fetchGeoJSON(baseUrl, context, transformRequest)
|
|
117
|
+
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
|
|
120
|
+
// Add/update features with deduplication
|
|
121
|
+
data.features.forEach(feature => {
|
|
122
|
+
const id = getFeatureId(feature)
|
|
123
|
+
if (id == null) {
|
|
124
|
+
console.warn('Feature missing ID, skipping:', feature)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
features.set(id, {
|
|
129
|
+
feature,
|
|
130
|
+
bbox: getGeometryBbox(feature.geometry),
|
|
131
|
+
addedAt: features.has(id) ? features.get(id).addedAt : now
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Expand tracked bbox
|
|
136
|
+
fetchedBbox = expandBbox(fetchedBbox, currentBbox)
|
|
137
|
+
|
|
138
|
+
// Evict if over limit
|
|
139
|
+
evictIfNeeded(currentBbox)
|
|
140
|
+
|
|
141
|
+
// Update map source
|
|
142
|
+
onUpdate(sourceId, toFeatureCollection())
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error(`Failed to fetch dynamic GeoJSON for ${dataset.id}:`, error)
|
|
145
|
+
} finally {
|
|
146
|
+
isLoading = false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Debounced fetch handler
|
|
151
|
+
const debouncedFetch = debounce(fetchData, DEBOUNCE_DELAY)
|
|
152
|
+
|
|
153
|
+
// Listen for map movements
|
|
154
|
+
const handleMoveEnd = () => {
|
|
155
|
+
debouncedFetch()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
map.on('moveend', handleMoveEnd)
|
|
159
|
+
|
|
160
|
+
// Initial fetch
|
|
161
|
+
fetchData()
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
/**
|
|
165
|
+
* Clean up event listeners
|
|
166
|
+
*/
|
|
167
|
+
destroy() {
|
|
168
|
+
map.off('moveend', handleMoveEnd)
|
|
169
|
+
debouncedFetch.cancel()
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Clear all cached features and reset fetch tracking
|
|
174
|
+
*/
|
|
175
|
+
clear() {
|
|
176
|
+
features.clear()
|
|
177
|
+
fetchedBbox = null
|
|
178
|
+
onUpdate(sourceId, { type: 'FeatureCollection', features: [] })
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Force refresh - clear cache and fetch current viewport
|
|
183
|
+
*/
|
|
184
|
+
refresh() {
|
|
185
|
+
features.clear()
|
|
186
|
+
fetchedBbox = null
|
|
187
|
+
fetchData()
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get current feature count
|
|
192
|
+
*/
|
|
193
|
+
getFeatureCount() {
|
|
194
|
+
return features.size
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Re-push cached features to the source (e.g., after style change)
|
|
199
|
+
*/
|
|
200
|
+
reapply() {
|
|
201
|
+
if (features.size > 0) {
|
|
202
|
+
onUpdate(sourceId, toFeatureCollection())
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch GeoJSON from URL with transformRequest
|
|
3
|
+
* @param {string} baseUrl - Base URL for the GeoJSON endpoint
|
|
4
|
+
* @param {Object} context - { bbox, zoom, dataset }
|
|
5
|
+
* @param {Function} transformRequest - Function to transform the request (builds URL with bbox, adds headers)
|
|
6
|
+
* @returns {Promise<Object>} GeoJSON FeatureCollection
|
|
7
|
+
*/
|
|
8
|
+
export const fetchGeoJSON = async (baseUrl, context, transformRequest) => {
|
|
9
|
+
const result = transformRequest(baseUrl, context)
|
|
10
|
+
|
|
11
|
+
// Handle both string and object return values
|
|
12
|
+
const config = typeof result === 'string' ? { url: result } : result
|
|
13
|
+
const { url, headers = {} } = config
|
|
14
|
+
|
|
15
|
+
const response = await fetch(url, { headers })
|
|
16
|
+
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`Failed to fetch GeoJSON: ${response.status} ${response.statusText}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const data = await response.json()
|
|
22
|
+
|
|
23
|
+
// Ensure we have a FeatureCollection
|
|
24
|
+
if (data.type === 'FeatureCollection') {
|
|
25
|
+
return data
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Wrap single feature or array of features
|
|
29
|
+
if (data.type === 'Feature') {
|
|
30
|
+
return { type: 'FeatureCollection', features: [data] }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(data)) {
|
|
34
|
+
return { type: 'FeatureCollection', features: data }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
throw new Error('Invalid GeoJSON response')
|
|
38
|
+
}
|
|
@@ -6,19 +6,26 @@ export const handleSetMapStyle = ({
|
|
|
6
6
|
events,
|
|
7
7
|
eventBus,
|
|
8
8
|
getDatasets,
|
|
9
|
-
getHiddenFeatures
|
|
9
|
+
getHiddenFeatures,
|
|
10
|
+
getDynamicSources
|
|
10
11
|
}) => {
|
|
11
12
|
const onSetStyle = (e) => {
|
|
12
13
|
map.once('idle', () => {
|
|
13
14
|
const newStyleId = e.id
|
|
14
15
|
const datasets = getDatasets()
|
|
15
16
|
const hiddenFeatures = getHiddenFeatures()
|
|
17
|
+
const dynamicSources = getDynamicSources ? getDynamicSources() : new Map()
|
|
16
18
|
|
|
17
19
|
// Re-add all layers with correct colors for new style
|
|
18
20
|
datasets.forEach(dataset => {
|
|
19
21
|
addMapLayers(map, newStyleId, dataset)
|
|
20
22
|
})
|
|
21
23
|
|
|
24
|
+
// Reapply cached data for dynamic sources
|
|
25
|
+
dynamicSources.forEach(source => {
|
|
26
|
+
source.reapply()
|
|
27
|
+
})
|
|
28
|
+
|
|
22
29
|
// Reapply hidden features filters
|
|
23
30
|
Object.entries(hiddenFeatures).forEach(([datasetId, { idProperty, ids }]) => {
|
|
24
31
|
const dataset = datasets.find(d => d.id === datasetId)
|
|
@@ -18,6 +18,10 @@ const getSourceId = (dataset) => {
|
|
|
18
18
|
return `tiles-${hashString(tilesKey)}`
|
|
19
19
|
}
|
|
20
20
|
if (dataset.geojson) {
|
|
21
|
+
// Dynamic sources get unique IDs per dataset (no sharing)
|
|
22
|
+
if (isDynamicSource(dataset)) {
|
|
23
|
+
return `geojson-dynamic-${dataset.id}`
|
|
24
|
+
}
|
|
21
25
|
// URL strings can be shared, inline GeoJSON objects get unique IDs per dataset
|
|
22
26
|
if (typeof dataset.geojson === 'string') {
|
|
23
27
|
return `geojson-${hashString(dataset.geojson)}`
|
|
@@ -29,6 +33,33 @@ const getSourceId = (dataset) => {
|
|
|
29
33
|
return `source-${dataset.id}`
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Check if a dataset uses dynamic fetching (bbox-based)
|
|
38
|
+
* Dynamic sources require: URL geojson, idProperty for deduplication, and transformRequest for URL building
|
|
39
|
+
* @param {Object} dataset
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
const isDynamicSource = (dataset) => {
|
|
43
|
+
return (
|
|
44
|
+
typeof dataset.geojson === 'string' &&
|
|
45
|
+
!!dataset.idProperty &&
|
|
46
|
+
typeof dataset.transformRequest === 'function'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Update the data for a GeoJSON source
|
|
52
|
+
* @param {Object} map - Map instance
|
|
53
|
+
* @param {string} sourceId - Source ID
|
|
54
|
+
* @param {Object} geojson - GeoJSON FeatureCollection
|
|
55
|
+
*/
|
|
56
|
+
const updateSourceData = (map, sourceId, geojson) => {
|
|
57
|
+
const source = map.getSource(sourceId)
|
|
58
|
+
if (source && typeof source.setData === 'function') {
|
|
59
|
+
source.setData(geojson)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
/**
|
|
33
64
|
* Get all layer IDs that use a given source
|
|
34
65
|
* @param {string} sourceId
|
|
@@ -60,10 +91,15 @@ const addMapLayers = (map, mapStyleId, dataset) => {
|
|
|
60
91
|
maxzoom: dataset.maxZoom || 22
|
|
61
92
|
})
|
|
62
93
|
} else if (dataset.geojson) {
|
|
63
|
-
//
|
|
94
|
+
// Dynamic source - start with empty FeatureCollection, will be populated by createDynamicSource
|
|
95
|
+
// Static source - use URL or inline GeoJSON directly
|
|
96
|
+
const initialData = isDynamicSource(dataset)
|
|
97
|
+
? { type: 'FeatureCollection', features: [] }
|
|
98
|
+
: dataset.geojson
|
|
99
|
+
|
|
64
100
|
map.addSource(sourceId, {
|
|
65
101
|
type: 'geojson',
|
|
66
|
-
data:
|
|
102
|
+
data: initialData
|
|
67
103
|
})
|
|
68
104
|
} else {
|
|
69
105
|
// No action
|
|
@@ -123,5 +159,7 @@ const addMapLayers = (map, mapStyleId, dataset) => {
|
|
|
123
159
|
export {
|
|
124
160
|
getSourceId,
|
|
125
161
|
getLayersUsingSource,
|
|
126
|
-
addMapLayers
|
|
162
|
+
addMapLayers,
|
|
163
|
+
isDynamicSource,
|
|
164
|
+
updateSourceData
|
|
127
165
|
}
|
|
@@ -1,4 +1,115 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Get bbox from map as array [west, south, east, north]
|
|
3
|
+
* @param {Object} map - Map instance
|
|
4
|
+
* @returns {number[]} bbox as [west, south, east, north]
|
|
5
|
+
*/
|
|
6
|
+
export const getBboxArray = (map) => {
|
|
2
7
|
const bounds = map.getBounds()
|
|
3
|
-
return
|
|
8
|
+
return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if inner bbox is fully contained within outer bbox
|
|
13
|
+
* @param {number[]} outer - [west, south, east, north]
|
|
14
|
+
* @param {number[]} inner - [west, south, east, north]
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export const bboxContains = (outer, inner) => {
|
|
18
|
+
if (!outer || !inner) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
return (
|
|
22
|
+
inner[0] >= outer[0] && // west
|
|
23
|
+
inner[1] >= outer[1] && // south
|
|
24
|
+
inner[2] <= outer[2] && // east
|
|
25
|
+
inner[3] <= outer[3] // north
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Expand bbox to include another bbox
|
|
31
|
+
* @param {number[]|null} existing - Current bbox or null
|
|
32
|
+
* @param {number[]} addition - Bbox to include
|
|
33
|
+
* @returns {number[]} Expanded bbox
|
|
34
|
+
*/
|
|
35
|
+
export const expandBbox = (existing, addition) => {
|
|
36
|
+
if (!existing) {
|
|
37
|
+
return [...addition]
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
Math.min(existing[0], addition[0]), // west
|
|
41
|
+
Math.min(existing[1], addition[1]), // south
|
|
42
|
+
Math.max(existing[2], addition[2]), // east
|
|
43
|
+
Math.max(existing[3], addition[3]) // north
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if two bboxes intersect
|
|
49
|
+
* @param {number[]} a - [west, south, east, north]
|
|
50
|
+
* @param {number[]} b - [west, south, east, north]
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export const bboxIntersects = (a, b) => {
|
|
54
|
+
if (!a || !b) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
return !(
|
|
58
|
+
a[2] < b[0] || // a is left of b
|
|
59
|
+
a[0] > b[2] || // a is right of b
|
|
60
|
+
a[3] < b[1] || // a is below b
|
|
61
|
+
a[1] > b[3] // a is above b
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get bbox from a GeoJSON geometry
|
|
67
|
+
* @param {Object} geometry - GeoJSON geometry
|
|
68
|
+
* @returns {number[]} bbox as [west, south, east, north]
|
|
69
|
+
*/
|
|
70
|
+
export const getGeometryBbox = (geometry) => {
|
|
71
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
72
|
+
|
|
73
|
+
const processCoord = (coord) => {
|
|
74
|
+
minX = Math.min(minX, coord[0])
|
|
75
|
+
minY = Math.min(minY, coord[1])
|
|
76
|
+
maxX = Math.max(maxX, coord[0])
|
|
77
|
+
maxY = Math.max(maxY, coord[1])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const processCoords = (coords, depth) => {
|
|
81
|
+
if (depth === 0) {
|
|
82
|
+
processCoord(coords)
|
|
83
|
+
} else {
|
|
84
|
+
coords.forEach(c => processCoords(c, depth - 1))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (geometry.type) {
|
|
89
|
+
case 'Point':
|
|
90
|
+
processCoords(geometry.coordinates, 0)
|
|
91
|
+
break
|
|
92
|
+
case 'LineString':
|
|
93
|
+
case 'MultiPoint':
|
|
94
|
+
processCoords(geometry.coordinates, 1)
|
|
95
|
+
break
|
|
96
|
+
case 'Polygon':
|
|
97
|
+
case 'MultiLineString':
|
|
98
|
+
processCoords(geometry.coordinates, 2)
|
|
99
|
+
break
|
|
100
|
+
case 'MultiPolygon':
|
|
101
|
+
processCoords(geometry.coordinates, 3)
|
|
102
|
+
break
|
|
103
|
+
case 'GeometryCollection':
|
|
104
|
+
geometry.geometries.forEach(g => {
|
|
105
|
+
const b = getGeometryBbox(g)
|
|
106
|
+
minX = Math.min(minX, b[0])
|
|
107
|
+
minY = Math.min(minY, b[1])
|
|
108
|
+
maxX = Math.max(maxX, b[2])
|
|
109
|
+
maxY = Math.max(maxY, b[3])
|
|
110
|
+
})
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [minX, minY, maxX, maxY]
|
|
4
115
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.touch-vertex-target circle{fill:var(--map-overlay-foreground-color)}.touch-vertex-target path{fill:var(--map-overlay-halo-color)}
|
|
1
|
+
.touch-vertex-target circle{fill:var(--map-overlay-foreground-color)}.touch-vertex-target path{fill:var(--map-overlay-halo-color)}.im-c-actions .im-c-button-wrapper--draw-done,.im-c-actions .im-c-button-wrapper--draw-menu,.im-c-actions .im-c-button-wrapper--draw-cancel{width:33.33%}.im-o-app--tablet .im-c-actions .im-c-button-wrapper--draw-done,.im-o-app--tablet .im-c-actions .im-c-button-wrapper--draw-menu,.im-o-app--tablet .im-c-actions .im-c-button-wrapper--draw-cancel,.im-o-app--desktop .im-c-actions .im-c-button-wrapper--draw-done,.im-o-app--desktop .im-c-actions .im-c-button-wrapper--draw-menu,.im-o-app--desktop .im-c-actions .im-c-button-wrapper--draw-cancel{width:100px}.im-c-button-wrapper--draw-add-point{position:absolute;bottom:calc(100% + var(--primary-gap))}
|