@defra/interactive-map 0.0.40-alpha → 0.0.42-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 (374) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/button-definition.md +7 -8
  7. package/docs/api/control-definition.md +17 -1
  8. package/docs/api/panel-definition.md +5 -0
  9. package/docs/api/slots.md +50 -6
  10. package/docs/assets/images/slot-map.svg +61 -262
  11. package/docs/examples/add-polygons.mdx +8 -2
  12. package/docs/examples/add-symbols.mdx +8 -2
  13. package/docs/examples/draw-tools.mdx +3 -3
  14. package/docs/plugins/datasets.md +23 -15
  15. package/docs/plugins/draw.md +79 -16
  16. package/docs/plugins/map-key.md +147 -0
  17. package/docs/plugins/search.md +1 -1
  18. package/docs/plugins.md +5 -1
  19. package/govuk-prototype-kit.config.json +2 -0
  20. package/jest.setup.js +5 -0
  21. package/package.json +9 -1
  22. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  23. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  24. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  25. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  26. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  27. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  28. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  29. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  30. package/plugins/beta/use-location/src/manifest.js +1 -1
  31. package/plugins/datasets/dist/css/index.css +1 -1
  32. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  33. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  34. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  35. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  36. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  37. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  38. package/plugins/datasets/dist/umd/index.js +1 -1
  39. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js +175 -0
  40. package/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js +55 -0
  41. package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +61 -0
  42. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +14 -0
  43. package/plugins/datasets/src/api/setOpacity.test.js +9 -0
  44. package/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx +10 -25
  45. package/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx +0 -42
  46. package/plugins/datasets/src/datasets.scss +0 -1
  47. package/plugins/datasets/src/fetch/createDynamicSource.js +135 -129
  48. package/plugins/datasets/src/fetch/createDynamicSource.test.js +318 -0
  49. package/plugins/datasets/src/fetch/fetchGeoJSON.test.js +83 -0
  50. package/plugins/datasets/src/index.test.js +36 -0
  51. package/plugins/datasets/src/initialise/DatasetsInit.jsx +17 -7
  52. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +262 -0
  53. package/plugins/datasets/src/initialise/initialiseDatasets.js +16 -4
  54. package/plugins/datasets/src/initialise/initialiseDatasets.test.js +42 -0
  55. package/plugins/datasets/src/manifest.js +0 -38
  56. package/plugins/datasets/src/manifest.test.js +49 -0
  57. package/plugins/datasets/src/reducers/datasetsToMenu.test.js +54 -1
  58. package/plugins/datasets/src/reducers/pluginState.js +3 -16
  59. package/plugins/datasets/src/reducers/pluginState.test.js +23 -0
  60. package/plugins/datasets/src/registry/dataset.js +13 -1
  61. package/plugins/datasets/src/registry/dataset.test.js +61 -0
  62. package/plugins/datasets/src/registry/datasetRegistry.js +12 -6
  63. package/plugins/datasets/src/registry/datasetRegistry.test.js +16 -4
  64. package/plugins/datasets/src/registry/isVisibleWhen.js +6 -3
  65. package/plugins/datasets/src/registry/isVisibleWhen.test.js +11 -1
  66. package/plugins/datasets/src/utils/bbox.test.js +125 -0
  67. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  68. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  69. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  70. package/plugins/draw/dist/esm/index.js +1 -1
  71. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +2 -1
  72. package/plugins/draw/dist/umd/im-draw-ml-adapter.js.LICENSE.txt +1 -0
  73. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  74. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  75. package/plugins/draw/dist/umd/index.js +1 -1
  76. package/plugins/draw/src/DrawInit.jsx +10 -2
  77. package/plugins/draw/src/DrawInit.test.jsx +51 -10
  78. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +91 -11
  79. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +246 -4
  80. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +109 -14
  81. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +174 -11
  82. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +12 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +9 -1
  84. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +24 -3
  85. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +194 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +457 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/__helpers__/harness.js +102 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +82 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.test.js +83 -0
  90. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +55 -0
  91. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.test.js +62 -0
  92. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.js +138 -0
  93. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.test.js +197 -0
  94. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.js +92 -0
  95. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.test.js +101 -0
  96. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.js +46 -0
  97. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.test.js +42 -0
  98. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +151 -0
  99. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +156 -0
  100. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +4 -27
  101. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +24 -39
  102. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +4 -5
  103. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +20 -23
  104. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +25 -5
  105. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +6 -36
  106. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -49
  107. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +3 -53
  108. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +8 -33
  109. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +29 -72
  110. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +10 -3
  111. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.js +158 -0
  112. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.test.js +314 -0
  113. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +6 -1
  114. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +21 -0
  115. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +6 -2
  116. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +12 -0
  117. package/plugins/draw/src/adapters/maplibre/styles.js +38 -11
  118. package/plugins/draw/src/adapters/maplibre/styles.test.js +39 -0
  119. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.js +79 -0
  120. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.test.js +135 -0
  121. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.js +42 -0
  122. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.test.js +85 -0
  123. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.js +34 -0
  124. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.test.js +78 -0
  125. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +16 -3
  126. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +21 -3
  127. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +59 -0
  128. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.test.js +73 -0
  129. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.js +33 -0
  130. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.test.js +70 -0
  131. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +27 -2
  132. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +93 -0
  133. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +9 -0
  134. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +19 -1
  135. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +34 -1
  136. package/plugins/draw/src/adapters/openlayers/core/styles.js +73 -1
  137. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +122 -3
  138. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +12 -0
  139. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +8 -2
  140. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +31 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +9 -5
  142. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +33 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +5 -3
  144. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +11 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +10 -3
  146. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +24 -1
  147. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +18 -13
  148. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +46 -1
  149. package/plugins/draw/src/adapters/openlayers/olDraw.js +11 -4
  150. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +48 -6
  151. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +156 -0
  152. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +246 -0
  153. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +267 -0
  154. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +327 -0
  155. package/plugins/draw/src/adapters/openlayers/point/pointDragInteraction.js +71 -0
  156. package/plugins/draw/src/adapters/openlayers/point/pointOps.js +34 -0
  157. package/plugins/draw/src/adapters/openlayers/point/pointOps.test.js +32 -0
  158. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.js +82 -0
  159. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.test.js +107 -0
  160. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.js +83 -0
  161. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.test.js +214 -0
  162. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +3 -1
  163. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +6 -9
  164. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +15 -4
  165. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +1 -1
  166. package/plugins/draw/src/api/addFeature.js +16 -1
  167. package/plugins/draw/src/api/addFeature.test.js +62 -5
  168. package/plugins/draw/src/api/createNewShape.js +41 -0
  169. package/plugins/draw/src/api/createNewShape.test.js +63 -0
  170. package/plugins/draw/src/api/editFeature.js +7 -4
  171. package/plugins/draw/src/api/editFeature.test.js +16 -0
  172. package/plugins/draw/src/api/newLine.js +2 -40
  173. package/plugins/draw/src/api/newPoint.js +42 -0
  174. package/plugins/draw/src/api/newPoint.test.js +117 -0
  175. package/plugins/draw/src/api/newPolygon.js +2 -40
  176. package/plugins/draw/src/api/setStyle.js +43 -0
  177. package/plugins/draw/src/api/setStyle.test.js +75 -0
  178. package/plugins/draw/src/events.js +17 -10
  179. package/plugins/draw/src/events.test.js +14 -0
  180. package/plugins/draw/src/manifest.js +18 -6
  181. package/plugins/draw/src/manifest.test.js +32 -1
  182. package/plugins/draw/src/reducer.test.js +7 -0
  183. package/plugins/draw/src/utils/stripInternalProperties.js +19 -0
  184. package/plugins/draw/src/utils/stripInternalProperties.test.js +50 -0
  185. package/plugins/draw/src/utils/symbolKeys.js +5 -0
  186. package/plugins/draw/src/validation/liveDrawChecks.test.js +54 -0
  187. package/plugins/draw/src/validation/liveStroke.test.js +64 -1
  188. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  189. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  190. package/plugins/interact/dist/umd/index.js +1 -1
  191. package/plugins/interact/src/hooks/useHighlightSync.js +33 -12
  192. package/plugins/interact/src/hooks/useHighlightSync.test.js +96 -25
  193. package/plugins/interact/src/manifest.js +1 -1
  194. package/plugins/interact/src/utils/featureQueries.js +18 -0
  195. package/plugins/interact/src/utils/featureQueries.test.js +15 -0
  196. package/plugins/interact/src/utils/spatial.js +9 -3
  197. package/plugins/interact/src/utils/spatial.test.js +12 -1
  198. package/plugins/map-key/dist/css/index.css +68 -0
  199. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -0
  200. package/plugins/map-key/dist/esm/index.js +1 -0
  201. package/plugins/map-key/dist/umd/im-map-key-plugin.js +2 -0
  202. package/plugins/map-key/dist/umd/im-map-key-plugin.js.LICENSE.txt +1 -0
  203. package/plugins/map-key/dist/umd/index.js +2 -0
  204. package/plugins/map-key/dist/umd/index.js.LICENSE.txt +1 -0
  205. package/plugins/map-key/package.json +28 -0
  206. package/plugins/map-key/src/components/Key/EmptyKey.jsx +7 -0
  207. package/plugins/{datasets → map-key}/src/components/Key/EmptyKey.test.jsx +2 -2
  208. package/plugins/map-key/src/components/Key/Key.jsx +43 -0
  209. package/plugins/{datasets → map-key}/src/components/Key/Key.module.scss +21 -14
  210. package/plugins/map-key/src/components/Key/Key.test.jsx +73 -0
  211. package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +16 -0
  212. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +53 -0
  213. package/plugins/map-key/src/components/Key/KeyItem.jsx +26 -0
  214. package/plugins/map-key/src/components/Key/KeyItem.test.jsx +67 -0
  215. package/plugins/map-key/src/components/Key/KeySvg.jsx +41 -0
  216. package/plugins/{datasets → map-key}/src/components/Key/KeySvg.test.jsx +14 -12
  217. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +22 -0
  218. package/plugins/{datasets → map-key}/src/components/Key/KeySvgLine.test.jsx +1 -1
  219. package/plugins/{datasets → map-key}/src/components/Key/KeySvgPattern.jsx +7 -2
  220. package/plugins/{datasets → map-key}/src/components/Key/KeySvgPattern.test.jsx +8 -2
  221. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +26 -0
  222. package/plugins/{datasets → map-key}/src/components/Key/KeySvgRect.test.jsx +1 -1
  223. package/plugins/{datasets → map-key}/src/components/Key/KeySvgSymbol.jsx +8 -3
  224. package/plugins/{datasets → map-key}/src/components/Key/KeySvgSymbol.test.jsx +17 -6
  225. package/plugins/map-key/src/components/Key/MapKey.jsx +53 -0
  226. package/plugins/map-key/src/components/Key/MapKey.test.jsx +147 -0
  227. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.js +2 -2
  228. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.test.js +2 -2
  229. package/plugins/map-key/src/index.js +13 -0
  230. package/plugins/map-key/src/index.test.js +31 -0
  231. package/plugins/map-key/src/initialise/MapKeyInit.jsx +15 -0
  232. package/plugins/map-key/src/initialise/MapKeyInit.test.jsx +39 -0
  233. package/plugins/map-key/src/manifest.js +30 -0
  234. package/plugins/map-key/src/manifest.test.js +63 -0
  235. package/plugins/map-key/src/mapKey.scss +4 -0
  236. package/plugins/map-key/src/registry/getDatasetRegistry.js +6 -0
  237. package/plugins/map-key/src/registry/getDatasetRegistry.test.js +23 -0
  238. package/plugins/map-key/src/registry/index.js +3 -0
  239. package/plugins/map-key/src/registry/index.test.js +15 -0
  240. package/plugins/menu/dist/css/index.css +80 -0
  241. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -0
  242. package/plugins/menu/dist/esm/index.js +1 -0
  243. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -0
  244. package/plugins/menu/dist/umd/index.js +2 -0
  245. package/plugins/menu/dist/umd/index.js.LICENSE.txt +1 -0
  246. package/plugins/menu/package.json +28 -0
  247. package/plugins/menu/src/components/Menu/CheckboxGroupWrapper.jsx +21 -0
  248. package/plugins/menu/src/components/Menu/GroupLegend.jsx +17 -0
  249. package/plugins/menu/src/components/Menu/GroupLegend.test.jsx +68 -0
  250. package/plugins/menu/src/components/Menu/Menu.jsx +19 -0
  251. package/plugins/menu/src/components/Menu/Menu.module.scss +90 -0
  252. package/plugins/menu/src/components/Menu/Menu.test.jsx +161 -0
  253. package/plugins/menu/src/components/Menu/MenuCheckbox.jsx +35 -0
  254. package/plugins/menu/src/components/Menu/MenuCheckbox.test.jsx +93 -0
  255. package/plugins/menu/src/components/Menu/MenuGroup.jsx +11 -0
  256. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.jsx → menu/src/components/Menu/MenuRadio.jsx} +6 -4
  257. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx → menu/src/components/Menu/MenuRadio.test.jsx} +18 -18
  258. package/plugins/menu/src/components/Menu/RadioGroupWrapper.jsx +32 -0
  259. package/plugins/{datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx → menu/src/components/Menu/RadioGroupWrapper.test.jsx} +21 -21
  260. package/plugins/menu/src/components/Menu/useIdPrefix.jsx +4 -0
  261. package/plugins/menu/src/index.js +13 -0
  262. package/plugins/menu/src/index.test.js +31 -0
  263. package/plugins/menu/src/initialise/MenuInit.jsx +24 -0
  264. package/plugins/menu/src/initialise/MenuInit.test.jsx +50 -0
  265. package/plugins/menu/src/initialise/createMenu.js +10 -0
  266. package/plugins/menu/src/initialise/createMenu.test.js +37 -0
  267. package/plugins/menu/src/manifest.js +35 -0
  268. package/plugins/menu/src/manifest.test.js +63 -0
  269. package/plugins/menu/src/menu.scss +4 -0
  270. package/plugins/menu/src/reducers/menuStateReducer.test.js +38 -0
  271. package/plugins/menu/src/reducers/pluginState.js +35 -0
  272. package/plugins/menu/src/reducers/pluginState.test.js +57 -0
  273. package/plugins/menu/src/registry/getDatasetRegistry.js +9 -0
  274. package/plugins/menu/src/registry/getDatasetRegistry.test.js +23 -0
  275. package/plugins/menu/src/registry/index.js +3 -0
  276. package/plugins/menu/src/registry/index.test.js +15 -0
  277. package/plugins/menu/src/registry/isVisibleWhen.js +43 -0
  278. package/plugins/menu/src/registry/isVisibleWhen.test.js +48 -0
  279. package/plugins/search/dist/css/index.css +1 -1
  280. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  281. package/plugins/search/dist/esm/index.js +1 -1
  282. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  283. package/plugins/search/dist/umd/index.js +1 -1
  284. package/plugins/search/src/Search.jsx +2 -3
  285. package/plugins/search/src/components/Form/Form.module.scss +9 -20
  286. package/plugins/search/src/index.js +0 -1
  287. package/plugins/search/src/index.test.js +2 -4
  288. package/plugins/search/src/manifest.js +1 -1
  289. package/plugins/search/src/search.scss +17 -16
  290. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  291. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  292. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  293. package/providers/beta/openlayers/src/openlayersProvider.js +4 -1
  294. package/providers/beta/openlayers/src/openlayersProvider.test.js +18 -0
  295. package/providers/beta/openlayers/src/utils/highlightFeatures.js +52 -5
  296. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +176 -0
  297. package/providers/beta/openlayers/src/utils/symbolImages.js +40 -0
  298. package/providers/beta/openlayers/src/utils/symbolImages.test.js +58 -0
  299. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  300. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  301. package/providers/maplibre/dist/umd/index.js +1 -1
  302. package/providers/maplibre/src/utils/drawLayerBuckets.js +14 -0
  303. package/providers/maplibre/src/utils/drawLayerBuckets.test.js +18 -0
  304. package/providers/maplibre/src/utils/highlightFeatures.js +80 -55
  305. package/providers/maplibre/src/utils/highlightFeatures.test.js +156 -17
  306. package/providers/maplibre/src/utils/hoverCursor.js +30 -14
  307. package/providers/maplibre/src/utils/hoverCursor.test.js +35 -0
  308. package/providers/maplibre/src/utils/patternImages.js +1 -1
  309. package/providers/maplibre/src/utils/symbolImages.js +41 -3
  310. package/providers/maplibre/src/utils/symbolImages.test.js +40 -6
  311. package/rollup.esm.mjs +11 -1
  312. package/src/App/components/Actions/Actions.test.jsx +47 -0
  313. package/src/App/components/Hints/Hints.test.jsx +13 -0
  314. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
  315. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
  316. package/src/App/components/MoveControls/MoveControls.module.scss +41 -10
  317. package/src/App/components/Panel/Panel.jsx +65 -8
  318. package/src/App/components/Panel/Panel.module.scss +49 -15
  319. package/src/App/components/Panel/Panel.test.jsx +114 -1
  320. package/src/App/components/Tabs/Tabs.jsx +43 -17
  321. package/src/App/components/Tabs/Tabs.module.scss +32 -5
  322. package/src/App/components/Tabs/Tabs.test.jsx +40 -10
  323. package/src/App/components/Tooltip/Tooltip.test.jsx +12 -14
  324. package/src/App/hooks/useFeatureItems.js +16 -7
  325. package/src/App/hooks/useFeatureItems.test.js +57 -0
  326. package/src/App/hooks/useLayoutMeasurements.js +95 -84
  327. package/src/App/hooks/useLayoutMeasurements.test.js +61 -1
  328. package/src/App/layout/Layout.jsx +9 -10
  329. package/src/App/layout/Layout.test.jsx +2 -0
  330. package/src/App/layout/layout.module.scss +31 -17
  331. package/src/App/registry/pluginRegistry.js +1 -1
  332. package/src/App/registry/pluginRegistry.test.js +12 -0
  333. package/src/App/renderer/HtmlElementHost.jsx +18 -5
  334. package/src/App/renderer/HtmlElementHost.test.jsx +54 -0
  335. package/src/App/renderer/groupByKey.js +28 -0
  336. package/src/App/renderer/groupByKey.test.js +39 -0
  337. package/src/App/renderer/groupIntoTabs.js +42 -0
  338. package/src/App/renderer/groupIntoTabs.test.js +95 -0
  339. package/src/App/renderer/mapButtons.js +97 -129
  340. package/src/App/renderer/mapButtons.test.js +67 -83
  341. package/src/App/renderer/mapControls.js +4 -1
  342. package/src/App/renderer/mapControls.test.js +32 -0
  343. package/src/App/renderer/mapPanels.js +53 -11
  344. package/src/App/renderer/mapPanels.test.js +95 -2
  345. package/src/App/renderer/orderItems.js +28 -0
  346. package/src/App/renderer/orderItems.test.js +50 -0
  347. package/src/App/renderer/slotAggregator.js +2 -18
  348. package/src/App/renderer/slotHelpers.js +3 -1
  349. package/src/App/renderer/slotHelpers.test.js +5 -0
  350. package/src/App/renderer/slots.js +3 -0
  351. package/src/App/store/AppProvider.jsx +1 -0
  352. package/src/App/store/appDispatchMiddleware.js +1 -1
  353. package/src/App/store/appDispatchMiddleware.test.js +8 -0
  354. package/src/config/appConfig.js +2 -2
  355. package/src/config/events.js +2 -0
  356. package/src/scss/settings/_colors.scss +1 -0
  357. package/src/scss/settings/_dimensions.scss +2 -3
  358. package/src/scss/settings/_transition.scss +1 -1
  359. package/src/services/eventBus.js +44 -0
  360. package/src/services/eventBus.test.js +83 -0
  361. package/src/services/symbolRegistry.js +1 -1
  362. package/src/types.js +31 -3
  363. package/webpack.umd.mjs +2 -0
  364. package/plugins/datasets/src/components/Key/EmptyKey.jsx +0 -7
  365. package/plugins/datasets/src/components/Key/Key.jsx +0 -50
  366. package/plugins/datasets/src/components/Key/KeyGroupItem.jsx +0 -18
  367. package/plugins/datasets/src/components/Key/KeyItem.jsx +0 -23
  368. package/plugins/datasets/src/components/Key/KeySvg.jsx +0 -22
  369. package/plugins/datasets/src/components/Key/KeySvgLine.jsx +0 -19
  370. package/plugins/datasets/src/components/Key/KeySvgRect.jsx +0 -22
  371. package/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +0 -41
  372. /package/plugins/{datasets → menu}/src/reducers/menuStateReducer.js +0 -0
  373. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.js +0 -0
  374. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.test.js +0 -0
@@ -6,152 +6,158 @@ const DEBOUNCE_DELAY = 200
6
6
  const EVICTION_THRESHOLD = 1.2 // Trigger eviction at 120% of maxFeatures
7
7
 
8
8
  /**
9
- * Create a dynamic GeoJSON source that fetches data based on viewport
10
- * @param {Object} options
11
- * @param {Object} options.registryDataset - registryDataset instance
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 }
9
+ * Convert features Map to FeatureCollection
16
10
  */
17
- export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
18
- const { url: baseUrl, idProperty, transformRequest, maxFeatures, minZoom = 0 } = dynamicGeoJSON
11
+ const toFeatureCollection = (features) => ({
12
+ type: 'FeatureCollection',
13
+ features: Array.from(features.values()).map(entry => entry.feature)
14
+ })
19
15
 
20
- // Feature cache: id → { feature, bbox, lastSeenAt }
21
- const features = new Map()
16
+ /**
17
+ * Get feature ID from a feature
18
+ */
19
+ const getFeatureId = (feature, idProperty) => {
20
+ if (idProperty) {
21
+ return feature.properties?.[idProperty] ?? feature.id
22
+ }
23
+ return feature.id
24
+ }
22
25
 
23
- // Track the bbox we've fetched data for
24
- let fetchedBbox = null
26
+ /**
27
+ * Evict features that are outside the current viewport
28
+ * Uses "least recently visible" strategy - evicts features furthest from viewport first
29
+ */
30
+ const evictIfNeeded = (features, currentBbox, maxFeatures) => {
31
+ if (!maxFeatures || features.size <= maxFeatures * EVICTION_THRESHOLD) {
32
+ return
33
+ }
25
34
 
26
- // Abort controller for the in-flight request
27
- let currentController = null
35
+ const targetSize = maxFeatures
28
36
 
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
- })
37
+ // Partition features: in-viewport vs out-of-viewport
38
+ const inView = []
39
+ const outOfView = []
36
40
 
37
- /**
38
- * Get feature ID from a feature
39
- */
40
- const getFeatureId = (feature) => {
41
- if (idProperty) {
42
- return feature.properties?.[idProperty] ?? feature.id
41
+ for (const [id, data] of features) {
42
+ if (bboxIntersects(data.bbox, currentBbox)) {
43
+ inView.push(id)
44
+ } else {
45
+ outOfView.push({ id, lastSeenAt: data.lastSeenAt })
43
46
  }
44
- return feature.id
45
47
  }
46
48
 
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
- }
49
+ // Sort out-of-view by last seen time (least recently seen first)
50
+ outOfView.sort((a, b) => a.lastSeenAt - b.lastSeenAt)
55
51
 
56
- const targetSize = maxFeatures
52
+ // Evict least-recently-seen out-of-view features until under target
53
+ const toEvict = features.size - targetSize
54
+ for (let i = 0; i < toEvict && i < outOfView.length; i++) {
55
+ features.delete(outOfView[i].id)
56
+ }
57
57
 
58
- // Partition features: in-viewport vs out-of-viewport
59
- const inView = []
60
- const outOfView = []
58
+ // If still over target (viewport has too many), evict least recently seen in-view
59
+ if (features.size > targetSize) {
60
+ const remaining = features.size - targetSize
61
+ const inViewSorted = inView
62
+ .map(id => ({ id, lastSeenAt: features.get(id).lastSeenAt }))
63
+ .sort((a, b) => a.lastSeenAt - b.lastSeenAt)
61
64
 
62
- for (const [id, data] of features) {
63
- if (bboxIntersects(data.bbox, currentBbox)) {
64
- inView.push(id)
65
- } else {
66
- outOfView.push({ id, lastSeenAt: data.lastSeenAt })
67
- }
65
+ for (let i = 0; i < remaining && i < inViewSorted.length; i++) {
66
+ features.delete(inViewSorted[i].id)
68
67
  }
68
+ }
69
+ }
69
70
 
70
- // Sort out-of-view by last seen time (least recently seen first)
71
- outOfView.sort((a, b) => a.lastSeenAt - b.lastSeenAt)
72
-
73
- // Evict least-recently-seen 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)
71
+ /**
72
+ * Merge freshly fetched GeoJSON features into the cache, deduplicating by ID
73
+ * and refreshing lastSeenAt for features that were re-fetched
74
+ */
75
+ const mergeFetchedFeatures = (features, data, idProperty) => {
76
+ const now = Date.now()
77
+ data.features.forEach(feature => {
78
+ const id = getFeatureId(feature, idProperty)
79
+ if (id == null) {
80
+ console.warn('Feature missing ID, skipping:', feature)
81
+ return
77
82
  }
78
83
 
79
- // If still over target (viewport has too many), evict least recently seen in-view
80
- if (features.size > targetSize) {
81
- const remaining = features.size - targetSize
82
- const inViewSorted = inView
83
- .map(id => ({ id, lastSeenAt: features.get(id).lastSeenAt }))
84
- .sort((a, b) => a.lastSeenAt - b.lastSeenAt)
84
+ features.set(id, {
85
+ feature,
86
+ bbox: getGeometryBbox(feature.geometry),
87
+ lastSeenAt: now
88
+ })
89
+ })
90
+ }
85
91
 
86
- for (let i = 0; i < remaining && i < inViewSorted.length; i++) {
87
- features.delete(inViewSorted[i].id)
88
- }
89
- }
92
+ /**
93
+ * Fetch data for the current viewport and merge it into the feature cache.
94
+ * Mutates state.fetchedBbox / state.currentController as a side effect.
95
+ */
96
+ const fetchViewportData = async (state, { map, dynamicGeoJSON, onUpdate }) => {
97
+ const { url: baseUrl, idProperty, transformRequest, maxFeatures, minZoom = 0 } = dynamicGeoJSON
98
+
99
+ const zoom = map.getZoom()
100
+ if (zoom < minZoom) {
101
+ return
90
102
  }
91
103
 
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
- }
104
+ const currentBbox = getBboxArray(map)
105
+
106
+ // Skip if current viewport is already covered
107
+ if (state.fetchedBbox && bboxContains(state.fetchedBbox, currentBbox)) {
108
+ return
109
+ }
100
110
 
101
- const currentBbox = getBboxArray(map)
111
+ // Abort any in-flight request — new viewport takes priority
112
+ if (state.currentController) {
113
+ state.currentController.abort()
114
+ }
115
+ state.currentController = new AbortController()
102
116
 
103
- // Skip if current viewport is already covered
104
- if (fetchedBbox && bboxContains(fetchedBbox, currentBbox)) {
105
- return
106
- }
117
+ try {
118
+ const context = { bbox: currentBbox, zoom }
119
+ const data = await fetchGeoJSON(baseUrl, context, transformRequest, state.currentController.signal)
107
120
 
108
- // Abort any in-flight request — new viewport takes priority
109
- if (currentController) {
110
- currentController.abort()
121
+ mergeFetchedFeatures(state.features, data, idProperty)
122
+
123
+ // Expand tracked bbox
124
+ state.fetchedBbox = expandBbox(state.fetchedBbox, currentBbox)
125
+
126
+ // Evict if over limit; if features were removed, fetchedBbox no longer
127
+ // covers those regions — reset to current viewport to force re-fetch on return
128
+ const sizeBeforeEviction = state.features.size
129
+ evictIfNeeded(state.features, currentBbox, maxFeatures)
130
+ if (state.features.size < sizeBeforeEviction) {
131
+ state.fetchedBbox = currentBbox
111
132
  }
112
- currentController = new AbortController()
113
-
114
- try {
115
- const context = { bbox: currentBbox, zoom }
116
- const data = await fetchGeoJSON(baseUrl, context, transformRequest, currentController.signal)
117
-
118
- const now = Date.now()
119
-
120
- // Add/update features with deduplication, refreshing lastSeenAt on each fetch
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
- lastSeenAt: now
132
- })
133
- })
134
-
135
- // Expand tracked bbox
136
- fetchedBbox = expandBbox(fetchedBbox, currentBbox)
137
-
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
141
- evictIfNeeded(currentBbox)
142
- if (features.size < sizeBeforeEviction) {
143
- fetchedBbox = currentBbox
144
- }
145
133
 
146
- // Update map source
147
- onUpdate(dynamicGeoJSON.id, toFeatureCollection())
148
- } catch (error) {
149
- if (error.name === 'AbortError') {
150
- return
151
- }
152
- console.error(`Failed to fetch dynamic GeoJSON for ${dynamicGeoJSON.id}:`, error)
134
+ // Update map source
135
+ onUpdate(dynamicGeoJSON.id, toFeatureCollection(state.features))
136
+ } catch (error) {
137
+ if (error.name === 'AbortError') {
138
+ return
153
139
  }
140
+ console.error(`Failed to fetch dynamic GeoJSON for ${dynamicGeoJSON.id}:`, error)
154
141
  }
142
+ }
143
+
144
+ /**
145
+ * Create a dynamic GeoJSON source that fetches data based on viewport
146
+ * @param {Object} options
147
+ * @param {Object} options.dynamicGeoJSON - dynamicGeoJSON config from the registry dataset
148
+ * @param {Object} options.map - Map instance
149
+ * @param {Function} options.onUpdate - Callback when source data should be updated
150
+ * @returns {Object} { destroy, clear, refresh, getFeatureCount, reapply }
151
+ */
152
+ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
153
+ // state.features: id → { feature, bbox, lastSeenAt }
154
+ const state = {
155
+ features: new Map(),
156
+ fetchedBbox: null,
157
+ currentController: null
158
+ }
159
+
160
+ const fetchData = () => fetchViewportData(state, { map, dynamicGeoJSON, onUpdate })
155
161
 
156
162
  // Debounced fetch handler
157
163
  const debouncedFetch = debounce(fetchData, DEBOUNCE_DELAY)
@@ -173,8 +179,8 @@ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
173
179
  destroy () {
174
180
  map.off('moveend', handleMoveEnd)
175
181
  debouncedFetch.cancel()
176
- if (currentController) {
177
- currentController.abort()
182
+ if (state.currentController) {
183
+ state.currentController.abort()
178
184
  }
179
185
  },
180
186
 
@@ -182,8 +188,8 @@ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
182
188
  * Clear all cached features and reset fetch tracking
183
189
  */
184
190
  clear () {
185
- features.clear()
186
- fetchedBbox = null
191
+ state.features.clear()
192
+ state.fetchedBbox = null
187
193
  onUpdate(dynamicGeoJSON.id, { type: 'FeatureCollection', features: [] })
188
194
  },
189
195
 
@@ -191,8 +197,8 @@ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
191
197
  * Force refresh - clear cache and fetch current viewport
192
198
  */
193
199
  refresh () {
194
- features.clear()
195
- fetchedBbox = null
200
+ state.features.clear()
201
+ state.fetchedBbox = null
196
202
  fetchData()
197
203
  },
198
204
 
@@ -200,15 +206,15 @@ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
200
206
  * Get current feature count
201
207
  */
202
208
  getFeatureCount () {
203
- return features.size
209
+ return state.features.size
204
210
  },
205
211
 
206
212
  /**
207
213
  * Re-push cached features to the source (e.g., after style change)
208
214
  */
209
215
  reapply () {
210
- if (features.size > 0) {
211
- onUpdate(dynamicGeoJSON.id, toFeatureCollection())
216
+ if (state.features.size > 0) {
217
+ onUpdate(dynamicGeoJSON.id, toFeatureCollection(state.features))
212
218
  }
213
219
  }
214
220
  }
@@ -0,0 +1,318 @@
1
+ import { createDynamicSource } from './createDynamicSource.js'
2
+ import { fetchGeoJSON } from './fetchGeoJSON.js'
3
+
4
+ jest.mock('./fetchGeoJSON.js', () => ({
5
+ fetchGeoJSON: jest.fn()
6
+ }))
7
+
8
+ // ─── helpers ─────────────────────────────────────────────────────────────────
9
+
10
+ const DEBOUNCE_DELAY = 200
11
+
12
+ const makeBounds = ([west, south, east, north]) => ({
13
+ getWest: () => west,
14
+ getSouth: () => south,
15
+ getEast: () => east,
16
+ getNorth: () => north
17
+ })
18
+
19
+ // Minimal map stub: tracks a mutable bbox/zoom and the registered moveend handler
20
+ const makeMap = ({ bbox = [-1, -1, 1, 1], zoom = 10 } = {}) => {
21
+ const state = { bbox, zoom }
22
+ const handlers = {}
23
+ return {
24
+ getZoom: jest.fn(() => state.zoom),
25
+ getBounds: jest.fn(() => makeBounds(state.bbox)),
26
+ on: jest.fn((event, handler) => { handlers[event] = handler }),
27
+ off: jest.fn(),
28
+ pan (bbox) { state.bbox = bbox },
29
+ fireMoveEnd () { handlers.moveend?.() }
30
+ }
31
+ }
32
+
33
+ const featureCollection = (features) => ({ type: 'FeatureCollection', features })
34
+
35
+ // Point feature - its bbox collapses to the point itself, handy for in/out-of-viewport tests
36
+ const makePointFeature = (id, [x, y], properties = {}) => ({
37
+ type: 'Feature',
38
+ id,
39
+ properties,
40
+ geometry: { type: 'Point', coordinates: [x, y] }
41
+ })
42
+
43
+ const flushMicrotasks = async () => {
44
+ await Promise.resolve()
45
+ await Promise.resolve()
46
+ }
47
+
48
+ const advanceDebounce = async () => {
49
+ jest.advanceTimersByTime(DEBOUNCE_DELAY)
50
+ await flushMicrotasks()
51
+ }
52
+
53
+ const makeDynamicGeoJSON = (overrides = {}) => ({
54
+ id: 'test-source',
55
+ url: 'https://api.example.com/features',
56
+ transformRequest: jest.fn((url, context) => ({ url: `${url}?bbox=${context.bbox.join(',')}` })),
57
+ ...overrides
58
+ })
59
+
60
+ beforeEach(() => {
61
+ jest.useFakeTimers()
62
+ fetchGeoJSON.mockReset()
63
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
64
+ jest.spyOn(console, 'error').mockImplementation(() => {})
65
+ })
66
+
67
+ afterEach(() => {
68
+ jest.useRealTimers()
69
+ console.warn.mockRestore()
70
+ console.error.mockRestore()
71
+ })
72
+
73
+ // ─── tests ─────────────────────────────────────────────────────────────────
74
+
75
+ describe('createDynamicSource', () => {
76
+ it('fetches the initial viewport on creation and pushes features to onUpdate', async () => {
77
+ const map = makeMap({ bbox: [-1, -1, 1, 1], zoom: 10 })
78
+ const onUpdate = jest.fn()
79
+ const dynamicGeoJSON = makeDynamicGeoJSON({ idProperty: 'featureId' })
80
+ const feature = makePointFeature('ignored', [0, 0], { featureId: 'abc' })
81
+ // No featureId in properties - falls back to feature.id despite idProperty being configured
82
+ const featureWithoutIdProperty = makePointFeature('fallback-id', [0, 0])
83
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([feature, featureWithoutIdProperty]))
84
+
85
+ const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate })
86
+ await flushMicrotasks()
87
+
88
+ expect(fetchGeoJSON).toHaveBeenCalledWith(
89
+ dynamicGeoJSON.url,
90
+ { bbox: [-1, -1, 1, 1], zoom: 10 },
91
+ dynamicGeoJSON.transformRequest,
92
+ expect.anything()
93
+ )
94
+ expect(instance.getFeatureCount()).toBe(2)
95
+ expect(onUpdate).toHaveBeenCalledWith('test-source', featureCollection([feature, featureWithoutIdProperty]))
96
+ })
97
+
98
+ it('falls back to feature.id when no idProperty is configured, and skips features with no resolvable id', async () => {
99
+ const map = makeMap()
100
+ const onUpdate = jest.fn()
101
+ const dynamicGeoJSON = makeDynamicGeoJSON()
102
+ const withId = makePointFeature('has-id', [0, 0])
103
+ const withoutId = { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } }
104
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([withId, withoutId]))
105
+
106
+ const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate })
107
+ await flushMicrotasks()
108
+
109
+ expect(instance.getFeatureCount()).toBe(1)
110
+ expect(console.warn).toHaveBeenCalledWith('Feature missing ID, skipping:', withoutId)
111
+ })
112
+
113
+ it('skips fetching below minZoom, and destroy() is a no-op with no in-flight request', async () => {
114
+ const map = makeMap({ zoom: 2 })
115
+ const dynamicGeoJSON = makeDynamicGeoJSON({ minZoom: 5 })
116
+
117
+ const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
118
+ await flushMicrotasks()
119
+
120
+ expect(fetchGeoJSON).not.toHaveBeenCalled()
121
+ expect(() => instance.destroy()).not.toThrow()
122
+ expect(map.off).toHaveBeenCalledWith('moveend', expect.any(Function))
123
+ })
124
+
125
+ it('skips re-fetching when the new viewport is already covered by the last fetch', async () => {
126
+ const map = makeMap({ bbox: [-1, -1, 1, 1] })
127
+ const dynamicGeoJSON = makeDynamicGeoJSON()
128
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([]))
129
+
130
+ createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
131
+ await flushMicrotasks()
132
+
133
+ map.pan([-0.5, -0.5, 0.5, 0.5]) // fully inside the fetched bbox
134
+ map.fireMoveEnd()
135
+ await advanceDebounce()
136
+
137
+ expect(fetchGeoJSON).toHaveBeenCalledTimes(1)
138
+ })
139
+
140
+ it('debounces rapid map movement into a single re-fetch', async () => {
141
+ const map = makeMap({ bbox: [-1, -1, 1, 1] })
142
+ const dynamicGeoJSON = makeDynamicGeoJSON()
143
+ fetchGeoJSON.mockResolvedValue(featureCollection([]))
144
+
145
+ createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
146
+ await flushMicrotasks()
147
+
148
+ map.pan([10, 10, 12, 12])
149
+ map.fireMoveEnd()
150
+ jest.advanceTimersByTime(50)
151
+ map.fireMoveEnd()
152
+ jest.advanceTimersByTime(50)
153
+ map.fireMoveEnd()
154
+ await advanceDebounce()
155
+
156
+ expect(fetchGeoJSON).toHaveBeenCalledTimes(2) // initial + one debounced re-fetch
157
+ })
158
+
159
+ it('aborts the previous in-flight request when a new viewport is fetched before it resolves', async () => {
160
+ let resolveFirst
161
+ fetchGeoJSON.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
162
+ const map = makeMap({ bbox: [-1, -1, 1, 1] })
163
+
164
+ createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map, onUpdate: jest.fn() })
165
+ await flushMicrotasks()
166
+ const firstSignal = fetchGeoJSON.mock.calls[0][3]
167
+ expect(firstSignal.aborted).toBe(false)
168
+
169
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([]))
170
+ map.pan([10, 10, 12, 12])
171
+ map.fireMoveEnd()
172
+ await advanceDebounce()
173
+
174
+ expect(firstSignal.aborted).toBe(true)
175
+ resolveFirst(featureCollection([]))
176
+ })
177
+
178
+ it('evicts least-recently-seen features once over the maxFeatures threshold, out-of-view first then in-view', async () => {
179
+ const map = makeMap({ bbox: [-1, -1, 1, 1] })
180
+ const dynamicGeoJSON = makeDynamicGeoJSON({ maxFeatures: 1 })
181
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('a', [0, 0])]))
182
+
183
+ const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
184
+ await flushMicrotasks()
185
+ expect(instance.getFeatureCount()).toBe(1) // under threshold (1 <= 1 * 1.2), no eviction yet
186
+
187
+ // New viewport, far from 'a': one in-view feature pushes total to 2 (> 1 * 1.2).
188
+ // Evicting the single out-of-view feature ('a') alone reaches the target, so the
189
+ // in-view eviction pass is skipped.
190
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('b', [10, 10])]))
191
+ map.pan([10, 10, 12, 12])
192
+ map.fireMoveEnd()
193
+ await advanceDebounce()
194
+ expect(instance.getFeatureCount()).toBe(1)
195
+
196
+ // New viewport again, far from 'b': two in-view features push total to 3 (> 1 * 1.2).
197
+ // 'b' is out-of-view and evicted first; still over target, so the least-recently-seen
198
+ // in-view feature ('c') is evicted next, leaving only 'd'.
199
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([
200
+ makePointFeature('c', [20, 20]),
201
+ makePointFeature('d', [21, 21])
202
+ ]))
203
+ map.pan([20, 20, 22, 22])
204
+ map.fireMoveEnd()
205
+ await advanceDebounce()
206
+ expect(instance.getFeatureCount()).toBe(1)
207
+ })
208
+
209
+ it('sorts multiple out-of-view features by lastSeenAt when evicting', async () => {
210
+ const map = makeMap({ bbox: [-1, -1, 1, 1] })
211
+ const dynamicGeoJSON = makeDynamicGeoJSON({ maxFeatures: 2 })
212
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([
213
+ makePointFeature('a', [0, 0]),
214
+ makePointFeature('b', [0.5, 0.5])
215
+ ]))
216
+
217
+ const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
218
+ await flushMicrotasks()
219
+ expect(instance.getFeatureCount()).toBe(2) // under threshold (2 <= 2 * 1.2), no eviction yet
220
+
221
+ // New viewport, far from 'a' and 'b': both become out-of-view at once, exercising the
222
+ // out-of-view sort-by-lastSeenAt comparator (a no-op array has nothing to compare)
223
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('c', [10, 10])]))
224
+ map.pan([10, 10, 12, 12])
225
+ map.fireMoveEnd()
226
+ await advanceDebounce()
227
+
228
+ expect(instance.getFeatureCount()).toBe(2) // one of a/b evicted, 'c' kept
229
+ })
230
+
231
+ it('swallows AbortError from the fetch without logging', async () => {
232
+ const error = Object.assign(new Error('aborted'), { name: 'AbortError' })
233
+ fetchGeoJSON.mockRejectedValueOnce(error)
234
+ const onUpdate = jest.fn()
235
+
236
+ createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate })
237
+ await flushMicrotasks()
238
+
239
+ expect(console.error).not.toHaveBeenCalled()
240
+ expect(onUpdate).not.toHaveBeenCalled()
241
+ })
242
+
243
+ it('logs unexpected fetch errors', async () => {
244
+ const error = new Error('network down')
245
+ fetchGeoJSON.mockRejectedValueOnce(error)
246
+
247
+ createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate: jest.fn() })
248
+ await flushMicrotasks()
249
+
250
+ expect(console.error).toHaveBeenCalledWith(
251
+ expect.stringContaining('Failed to fetch dynamic GeoJSON for test-source'),
252
+ error
253
+ )
254
+ })
255
+
256
+ it('destroy() unregisters moveend, cancels the pending debounce, and aborts the in-flight request', async () => {
257
+ fetchGeoJSON.mockImplementationOnce(() => new Promise(() => {})) // never resolves
258
+ const map = makeMap()
259
+
260
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map, onUpdate: jest.fn() })
261
+ await flushMicrotasks()
262
+ const signal = fetchGeoJSON.mock.calls[0][3]
263
+
264
+ map.fireMoveEnd() // schedule a debounced re-fetch
265
+ instance.destroy()
266
+
267
+ expect(map.off).toHaveBeenCalledWith('moveend', expect.any(Function))
268
+ expect(signal.aborted).toBe(true)
269
+
270
+ await advanceDebounce()
271
+ expect(fetchGeoJSON).toHaveBeenCalledTimes(1) // debounced call never fired
272
+ })
273
+
274
+ it('clear() empties the cache and pushes an empty FeatureCollection', async () => {
275
+ const onUpdate = jest.fn()
276
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('a', [0, 0])]))
277
+
278
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate })
279
+ await flushMicrotasks()
280
+
281
+ instance.clear()
282
+
283
+ expect(instance.getFeatureCount()).toBe(0)
284
+ expect(onUpdate).toHaveBeenLastCalledWith('test-source', featureCollection([]))
285
+ })
286
+
287
+ it('refresh() clears the cache and re-fetches the current viewport', async () => {
288
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('a', [0, 0])]))
289
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate: jest.fn() })
290
+ await flushMicrotasks()
291
+
292
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('b', [0, 0])]))
293
+ instance.refresh()
294
+ await flushMicrotasks()
295
+
296
+ expect(fetchGeoJSON).toHaveBeenCalledTimes(2)
297
+ expect(instance.getFeatureCount()).toBe(1)
298
+ })
299
+
300
+ it('reapply() re-pushes cached features, and does nothing when the cache is empty', async () => {
301
+ const onUpdate = jest.fn()
302
+ const feature = makePointFeature('a', [0, 0])
303
+ fetchGeoJSON.mockResolvedValueOnce(featureCollection([feature]))
304
+
305
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate })
306
+ await flushMicrotasks()
307
+ onUpdate.mockClear()
308
+
309
+ instance.reapply()
310
+ expect(onUpdate).toHaveBeenCalledWith('test-source', featureCollection([feature]))
311
+
312
+ onUpdate.mockClear()
313
+ instance.clear()
314
+ onUpdate.mockClear()
315
+ instance.reapply()
316
+ expect(onUpdate).not.toHaveBeenCalled()
317
+ })
318
+ })