@defra/interactive-map 0.0.4-alpha → 0.0.5-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.
Files changed (202) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/index.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +8 -0
  7. package/package.json +3 -2
  8. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
  9. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
  10. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
  11. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
  12. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  13. package/plugins/beta/datasets/src/datasets.js +55 -2
  14. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
  15. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
  16. package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
  17. package/plugins/beta/datasets/src/mapLayers.js +41 -3
  18. package/plugins/beta/datasets/src/utils/bbox.js +113 -2
  19. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  23. package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
  24. package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
  25. package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
  26. package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
  27. package/plugins/beta/draw-ml/src/defaults.js +1 -0
  28. package/plugins/beta/draw-ml/src/draw.scss +23 -0
  29. package/plugins/beta/draw-ml/src/events.js +55 -113
  30. package/plugins/beta/draw-ml/src/manifest.js +64 -38
  31. package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
  32. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
  33. package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
  34. package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
  35. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
  36. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
  37. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
  38. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
  39. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
  40. package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
  41. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  42. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  43. package/plugins/beta/frame/src/Frame.jsx +1 -1
  44. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  45. package/plugins/beta/map-styles/dist/esm/index.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
  49. package/plugins/interact/dist/css/index.css +1 -1
  50. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/index.js +1 -1
  53. package/plugins/interact/src/InteractInit.jsx +1 -1
  54. package/plugins/interact/src/defaults.js +1 -0
  55. package/plugins/interact/src/events.js +37 -52
  56. package/plugins/interact/src/events.test.js +35 -0
  57. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  58. package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
  59. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
  60. package/plugins/interact/src/interact.scss +6 -0
  61. package/plugins/interact/src/reducer.js +25 -27
  62. package/plugins/interact/src/reducer.test.js +19 -6
  63. package/plugins/interact/src/utils/featureQueries.js +2 -2
  64. package/plugins/interact/src/utils/spatial.js +95 -0
  65. package/plugins/interact/src/utils/spatial.test.js +93 -0
  66. package/plugins/search/dist/css/index.css +1 -1
  67. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  68. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  69. package/plugins/search/dist/umd/index.js +1 -1
  70. package/plugins/search/src/Search.jsx +2 -2
  71. package/plugins/search/src/components/Form/Form.jsx +1 -1
  72. package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
  73. package/plugins/search/src/datasets.js +1 -1
  74. package/plugins/search/src/defaults.js +3 -0
  75. package/plugins/search/src/events/fetchSuggestions.js +48 -53
  76. package/plugins/search/src/events/formHandlers.js +3 -2
  77. package/plugins/search/src/events/index.js +1 -1
  78. package/plugins/search/src/events/inputHandlers.js +3 -1
  79. package/plugins/search/src/events/suggestionHandlers.js +12 -3
  80. package/plugins/search/src/search.scss +4 -1
  81. package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
  82. package/providers/beta/esri/src/esriProvider.js +1 -1
  83. package/providers/beta/esri/src/index.js +1 -1
  84. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/esm/index.js +1 -1
  86. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  87. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  88. package/providers/maplibre/dist/umd/index.js +1 -1
  89. package/providers/maplibre/src/defaults.js +3 -2
  90. package/providers/maplibre/src/index.js +22 -19
  91. package/providers/maplibre/src/maplibreProvider.js +13 -11
  92. package/providers/maplibre/src/utils/detectWebgl.js +1 -2
  93. package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
  94. package/providers/maplibre/src/utils/labels.js +174 -120
  95. package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
  96. package/providers/maplibre/src/utils/queryFeatures.js +146 -0
  97. package/providers/maplibre/src/utils/spatial.js +14 -17
  98. package/sonar-project.properties +39 -2
  99. package/src/App/components/Actions/Actions.jsx +2 -2
  100. package/src/App/components/Actions/Actions.module.scss +2 -2
  101. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
  102. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
  103. package/src/App/components/Logo/Logo.jsx +1 -1
  104. package/src/App/components/MapButton/MapButton.jsx +217 -41
  105. package/src/App/components/MapButton/MapButton.module.scss +32 -21
  106. package/src/App/components/MapButton/MapButton.test.jsx +128 -84
  107. package/src/App/components/Markers/Markers.jsx +1 -1
  108. package/src/App/components/Markers/Markers.module.scss +0 -5
  109. package/src/App/components/Panel/Panel.jsx +75 -45
  110. package/src/App/components/Panel/Panel.module.scss +18 -24
  111. package/src/App/components/Panel/Panel.test.jsx +18 -0
  112. package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
  113. package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
  114. package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
  115. package/src/App/components/Tooltip/Tooltip.jsx +4 -2
  116. package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
  117. package/src/App/components/Viewport/MapController.jsx +1 -1
  118. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  119. package/src/App/components/Viewport/Viewport.jsx +2 -2
  120. package/src/App/components/Viewport/Viewport.module.scss +2 -2
  121. package/src/App/controls/keyboardActions.js +3 -2
  122. package/src/App/controls/keyboardShortcuts.js +4 -2
  123. package/src/App/hooks/useButtonStateEvaluator.js +58 -45
  124. package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
  125. package/src/App/hooks/useCrossHairAPI.js +49 -47
  126. package/src/App/hooks/useFocusVisible.js +2 -2
  127. package/src/App/hooks/useInterfaceAPI.js +16 -4
  128. package/src/App/hooks/useKeyboardHint.js +1 -1
  129. package/src/App/hooks/useKeyboardShortcuts.js +4 -2
  130. package/src/App/hooks/useMapAnnouncements.js +34 -32
  131. package/src/App/hooks/useMapEvents.js +12 -1
  132. package/src/App/hooks/useMapProviderOverrides.js +3 -3
  133. package/src/App/hooks/useMapStateSync.js +7 -1
  134. package/src/App/hooks/useMapURLSync.js +6 -1
  135. package/src/App/hooks/useMarkersAPI.js +88 -50
  136. package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
  137. package/src/App/hooks/useModalPanelBehaviour.js +46 -40
  138. package/src/App/hooks/useResizeObserver.js +2 -2
  139. package/src/App/initialiseApp.js +45 -38
  140. package/src/App/layout/Layout.jsx +6 -4
  141. package/src/App/layout/layout.module.scss +13 -17
  142. package/src/App/registry/keyboardShortcutRegistry.js +12 -2
  143. package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
  144. package/src/App/registry/mergeManifests.js +1 -1
  145. package/src/App/registry/panelRegistry.js +1 -1
  146. package/src/App/registry/panelRegistry.test.js +30 -1
  147. package/src/App/registry/pluginRegistry.js +14 -51
  148. package/src/App/registry/pluginRegistry.test.js +54 -6
  149. package/src/App/renderer/HtmlElementHost.jsx +186 -0
  150. package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
  151. package/src/App/renderer/mapButtons.js +11 -8
  152. package/src/App/renderer/mapButtons.test.js +6 -4
  153. package/src/App/renderer/mapControls.js +6 -0
  154. package/src/App/renderer/mapControls.test.js +10 -2
  155. package/src/App/renderer/mapPanels.js +39 -26
  156. package/src/App/renderer/mapPanels.test.js +29 -0
  157. package/src/App/renderer/pluginWrapper.test.js +28 -0
  158. package/src/App/renderer/slotHelpers.js +60 -0
  159. package/src/App/renderer/slotHelpers.test.js +82 -0
  160. package/src/App/store/AppProvider.jsx +3 -0
  161. package/src/App/store/AppProvider.test.jsx +40 -0
  162. package/src/App/store/PluginProvider.jsx +7 -5
  163. package/src/App/store/appActionsMap.js +25 -1
  164. package/src/App/store/appActionsMap.test.js +21 -0
  165. package/src/App/store/appReducer.js +1 -0
  166. package/src/App/store/mapActionsMap.js +12 -24
  167. package/src/App/store/mapReducer.js +1 -1
  168. package/src/InteractiveMap/InteractiveMap.js +79 -6
  169. package/src/InteractiveMap/InteractiveMap.test.js +164 -2
  170. package/src/InteractiveMap/behaviourController.js +12 -2
  171. package/src/InteractiveMap/behaviourController.test.js +82 -1
  172. package/src/InteractiveMap/deviceChecker.js +1 -1
  173. package/src/InteractiveMap/deviceChecker.test.js +2 -2
  174. package/src/InteractiveMap/historyManager.js +41 -5
  175. package/src/InteractiveMap/historyManager.test.js +37 -6
  176. package/src/InteractiveMap/polyfills.js +39 -0
  177. package/src/InteractiveMap/polyfills.test.js +127 -0
  178. package/src/config/appConfig.js +5 -5
  179. package/src/config/appConfig.test.js +107 -42
  180. package/src/config/defaults.js +1 -0
  181. package/src/index.umd.js +1 -1
  182. package/src/scss/main.scss +1 -0
  183. package/src/scss/settings/_dimensions.scss +6 -2
  184. package/src/services/eventBus.test.js +24 -1
  185. package/src/test-utils.js +1 -0
  186. package/src/types.js +5 -0
  187. package/src/utils/detectBreakpoint.js +78 -77
  188. package/src/utils/detectBreakpoint.test.js +50 -4
  189. package/src/utils/detectInterfaceType.js +4 -4
  190. package/src/utils/getIsFullscreen.js +3 -1
  191. package/src/utils/getSafeZoneInset.js +23 -7
  192. package/src/utils/getSafeZoneInset.test.js +18 -0
  193. package/src/utils/mapStateSync.js +3 -3
  194. package/src/utils/queryString.js +1 -1
  195. package/src/utils/stringToKebab.js +1 -1
  196. package/src/utils/toggleInertElements.js +37 -12
  197. package/webpack.dev.mjs +3 -4
  198. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  199. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  200. package/plugins/search/src/utils/fetchDataset.js +0 -23
  201. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  202. 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
- // GeoJSON URL
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: dataset.geojson
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
- export const getBbox = (map) => {
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 `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`
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))}