@defra/interactive-map 0.0.18-alpha → 0.0.20-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 (91) hide show
  1. package/README.md +1 -1
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +22 -5
  8. package/docs/api/map-style-config.md +2 -11
  9. package/docs/api/marker-config.md +19 -5
  10. package/docs/api/panel-definition.md +16 -0
  11. package/docs/api.md +101 -48
  12. package/docs/architecture.md +0 -2
  13. package/docs/assets/css/docusaurus.css +205 -0
  14. package/docs/assets/images/basic-map.jpg +0 -0
  15. package/docs/assets/images/button-first.jpg +0 -0
  16. package/{assets → docs/assets}/images/hero.png +0 -0
  17. package/docs/assets/images/marker-panel.jpg +0 -0
  18. package/docs/examples/add-marker-with-panel.mdx +123 -0
  19. package/docs/examples/basic-map.mdx +53 -0
  20. package/docs/examples/button-map.mdx +53 -0
  21. package/docs/examples/index.mdx +49 -0
  22. package/docs/getting-started.md +80 -53
  23. package/docs/index.mdx +1 -1
  24. package/docs/plugins/interact.md +32 -1
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +14 -5
  27. package/package.json +3 -3
  28. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  31. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  32. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  33. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  34. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  35. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  36. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  37. package/plugins/interact/dist/umd/index.js +1 -1
  38. package/plugins/interact/src/InteractInit.jsx +5 -3
  39. package/plugins/interact/src/api/clear.js +1 -1
  40. package/plugins/interact/src/api/selectMarker.js +14 -0
  41. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  42. package/plugins/interact/src/api/unselectMarker.js +14 -0
  43. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  44. package/plugins/interact/src/events.js +18 -30
  45. package/plugins/interact/src/events.test.js +113 -108
  46. package/plugins/interact/src/manifest.js +10 -2
  47. package/plugins/interact/src/reducer.js +36 -1
  48. package/plugins/interact/src/reducer.test.js +40 -1
  49. package/plugins/interact/src/utils/interactionModes.js +12 -0
  50. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  51. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  52. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  53. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  54. package/providers/maplibre/src/maplibreProvider.js +3 -2
  55. package/providers/maplibre/src/maplibreProvider.test.js +22 -0
  56. package/src/App/components/Panel/Panel.jsx +6 -6
  57. package/src/App/components/Panel/Panel.test.jsx +37 -0
  58. package/src/App/components/Viewport/Viewport.jsx +5 -15
  59. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  60. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  61. package/src/App/hooks/useInterfaceAPI.js +7 -7
  62. package/src/App/hooks/useInterfaceAPI.test.js +15 -9
  63. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  64. package/src/App/layout/Layout.jsx +1 -1
  65. package/src/App/layout/layout.module.scss +1 -8
  66. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  67. package/src/App/renderer/mapPanels.js +2 -1
  68. package/src/App/store/appActionsMap.js +4 -4
  69. package/src/App/store/appActionsMap.test.js +10 -0
  70. package/src/InteractiveMap/InteractiveMap.js +61 -13
  71. package/src/InteractiveMap/InteractiveMap.test.js +130 -8
  72. package/src/InteractiveMap/domStateManager.js +18 -6
  73. package/src/InteractiveMap/domStateManager.test.js +21 -0
  74. package/src/InteractiveMap/historyManager.js +28 -16
  75. package/src/InteractiveMap/historyManager.test.js +17 -0
  76. package/src/config/appConfig.js +2 -1
  77. package/src/config/appConfig.test.js +3 -13
  78. package/src/config/defaults.js +2 -1
  79. package/src/config/events.js +20 -21
  80. package/src/services/closeApp.js +1 -10
  81. package/src/services/closeApp.test.js +3 -43
  82. package/src/services/eventBus.js +61 -0
  83. package/src/services/eventBus.test.js +70 -0
  84. package/src/types.js +6 -1
  85. package/src/utils/mapStateSync.js +48 -10
  86. package/src/utils/mapStateSync.test.js +29 -9
  87. package/assets/css/docusaurus.css +0 -104
  88. package/docs/examples.mdx +0 -70
  89. /package/docs/assets/{screens-blue.jpg → images/screens-blue.jpg} +0 -0
  90. /package/docs/assets/{screens-white.jpg → images/screens-white.jpg} +0 -0
  91. /package/{assets → docs/assets}/images/slot-map.svg +0 -0
@@ -94,6 +94,76 @@ describe('EventBus singleton', () => {
94
94
  })
95
95
  })
96
96
 
97
+ describe('emitWhenReady', () => {
98
+ beforeEach(() => {
99
+ eventBus.destroy()
100
+ })
101
+
102
+ it('fires immediately when listeners are already registered', () => {
103
+ const handler = jest.fn()
104
+ eventBus.on('ready', handler)
105
+
106
+ eventBus.emitWhenReady('ready', 'value')
107
+
108
+ expect(handler).toHaveBeenCalledWith('value')
109
+ })
110
+
111
+ it('queues the call and replays to the first subscriber when no listeners exist', () => {
112
+ const handler = jest.fn()
113
+
114
+ eventBus.emitWhenReady('ready', 'value')
115
+ expect(handler).not.toHaveBeenCalled()
116
+
117
+ eventBus.on('ready', handler)
118
+ expect(handler).toHaveBeenCalledWith('value')
119
+ })
120
+
121
+ it('clears the queue after replaying so subsequent subscribers do not receive it', () => {
122
+ const first = jest.fn()
123
+ const second = jest.fn()
124
+
125
+ eventBus.emitWhenReady('ready', 'value')
126
+ eventBus.on('ready', first)
127
+ eventBus.on('ready', second)
128
+
129
+ expect(first).toHaveBeenCalledWith('value')
130
+ expect(second).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('replaces a queued value if emitWhenReady is called again before a subscriber arrives', () => {
134
+ const handler = jest.fn()
135
+
136
+ eventBus.emitWhenReady('ready', 'first')
137
+ eventBus.emitWhenReady('ready', 'second')
138
+ eventBus.on('ready', handler)
139
+
140
+ expect(handler).toHaveBeenCalledTimes(1)
141
+ expect(handler).toHaveBeenCalledWith('second')
142
+ })
143
+
144
+ it('catches and logs errors thrown by a handler during queued replay', () => {
145
+ const error = new Error('boom')
146
+ const handler = jest.fn(() => { throw error })
147
+ jest.spyOn(console, 'error').mockImplementation(() => {})
148
+
149
+ eventBus.emitWhenReady('ready', 'value')
150
+ eventBus.on('ready', handler)
151
+
152
+ expect(console.error).toHaveBeenCalledWith("Error in event handler for 'ready':", error)
153
+ console.error.mockRestore()
154
+ })
155
+
156
+ it('clears queued events on destroy', () => {
157
+ const handler = jest.fn()
158
+
159
+ eventBus.emitWhenReady('ready', 'value')
160
+ eventBus.destroy()
161
+ eventBus.on('ready', handler)
162
+
163
+ expect(handler).not.toHaveBeenCalled()
164
+ })
165
+ })
166
+
97
167
  describe('createEventBus factory', () => {
98
168
  /**
99
169
  * Test to ensure coverage for the factory function (Line 50).
package/src/types.js CHANGED
@@ -591,7 +591,7 @@
591
591
  * @property {boolean} [enableFullscreen=false]
592
592
  * Whether a toggle fullscreen button is displayed.
593
593
  *
594
- * @property {boolean} [enableZoomControls=false]
594
+ * @property {boolean} [enableZoomControls=true]
595
595
  * Whether zoom control buttons are displayed.
596
596
  *
597
597
  * @property {[number, number, number, number]} [extent]
@@ -661,6 +661,11 @@
661
661
  * @property {PluginDescriptor[]} [plugins]
662
662
  * Plugins to load.
663
663
  *
664
+ * @property {boolean} [manageHistoryState=true]
665
+ * Whether the library should manage browser history state (pushState/replaceState) when opening and closing the map.
666
+ * Set to `false` in SPA frameworks (e.g. React Router, Docusaurus) that intercept history API calls.
667
+ * When `false`, listen to `APP_OPENED` and `APP_CLOSED` events and manage navigation in your router instead.
668
+ *
664
669
  * @property {boolean} [preserveStateOnClose=false]
665
670
  * Whether to preserve the map state when closed via back button or exit button.
666
671
  * When true, the map is hidden but not destroyed, preserving markers, zoom, etc.
@@ -1,4 +1,11 @@
1
- // Internal (not exported) - Accept optional search string for testing
1
+ /**
2
+ * Reads center and zoom for a given map ID from a URL search string.
3
+ * The `search` parameter is accepted explicitly to keep this function pure and testable.
4
+ *
5
+ * @param {string} id - Map instance ID.
6
+ * @param {string} search - URL search string (e.g. `window.location.search`).
7
+ * @returns {{ center: [number, number], zoom: number } | null}
8
+ */
2
9
  const getMapStateFromURL = (id, search) => {
3
10
  const params = new URLSearchParams(search)
4
11
  const centerStr = params.get(`${id}:center`)
@@ -11,30 +18,61 @@ const getMapStateFromURL = (id, search) => {
11
18
  return { center: [lng, lat], zoom }
12
19
  }
13
20
 
21
+ /**
22
+ * Persists map center and zoom into the page URL without triggering navigation.
23
+ *
24
+ * Existing query parameters are preserved. Parameters for this map ID are
25
+ * replaced if already present. Builds the URL manually to avoid percent-encoding
26
+ * colons and commas that URLSearchParams would otherwise encode.
27
+ *
28
+ * @param {string} id - Map instance ID, used as a namespace prefix for the params.
29
+ * @param {{ center?: [number, number], zoom?: number }} state - Map state to write.
30
+ * @param {string} [currentHref] - URL to update. Defaults to `window.location.href`.
31
+ * @returns {void}
32
+ */
14
33
  const setMapStateInURL = (id, state, currentHref = window.location.href) => {
15
- // Use the passed href or the global one
16
34
  const url = new URL(currentHref || 'http://localhost')
17
- const params = [...new URLSearchParams(url.search)].map(([key, value]) => `${key}=${value}`)
35
+
36
+ const newKeys = new Set()
18
37
  const newParams = []
19
38
 
20
39
  if (state.center) {
21
- newParams.push(`${id}:center=${state.center[0]},${state.center[1]}`)
40
+ const key = `${id}:center`
41
+ newKeys.add(key)
42
+ newParams.push(`${key}=${state.center[0]},${state.center[1]}`)
22
43
  }
23
44
  if (state.zoom != null) {
24
- newParams.push(`${id}:zoom=${state.zoom}`)
45
+ const key = `${id}:zoom`
46
+ newKeys.add(key)
47
+ newParams.push(`${key}=${state.zoom}`)
25
48
  }
26
49
 
27
- const filteredParams = params.filter(p => {
28
- return !newParams.some(np => np.split('=')[0] === p.split('=')[0])
50
+ const existingParams = []
51
+ url.searchParams.forEach((value, key) => {
52
+ if (!newKeys.has(key)) {
53
+ existingParams.push(`${key}=${value}`)
54
+ }
29
55
  })
30
56
 
31
- const hash = url.hash || ''
32
- const newUrl = `${url.origin}${url.pathname}?${[...filteredParams, ...newParams].join('&')}${hash}`
57
+ const allParams = [...existingParams, ...newParams].join('&')
58
+ const search = allParams ? '?' + allParams : ''
59
+ const newUrl = `${url.origin}${url.pathname}${search}${url.hash}`
33
60
  window.history.replaceState(window.history.state, '', newUrl)
34
61
  }
35
62
 
63
+ /**
64
+ * Returns the initial map view state, preferring any saved state from the URL.
65
+ *
66
+ * Resolution order:
67
+ * 1. Center/zoom encoded in the URL search string for this map ID.
68
+ * 2. A `bounds` value if provided (used to fit the view on load).
69
+ * 3. The configured `center` and `zoom` defaults.
70
+ *
71
+ * @param {{ id: string, center: [number, number], zoom: number, bounds?: any }} config - Map config.
72
+ * @param {string} [search] - URL search string. Defaults to `window.location.search`.
73
+ * @returns {{ center: [number, number], zoom: number } | { bounds: any }}
74
+ */
36
75
  const getInitialMapState = ({ id, center, zoom, bounds }, search = window.location.search) => {
37
- // Pass search string down to the internal function
38
76
  const savedState = getMapStateFromURL(id, search)
39
77
  if (savedState) {
40
78
  return {
@@ -33,11 +33,10 @@ describe('mapStateSync utilities', () => {
33
33
  })
34
34
 
35
35
  describe('setMapStateInURL', () => {
36
- it('preserves history state and filters params (Line 28)', () => {
36
+ it('writes center and zoom into the URL', () => {
37
37
  const mockHref = 'http://test.com/path?existing=true'
38
38
  setMapStateInURL('map1', { center: [10, 20], zoom: 5 }, mockHref)
39
39
 
40
- // Verification of Line 28: First arg must be global history state
41
40
  expect(globalThis.history.replaceState).toHaveBeenCalledWith(
42
41
  globalThis.history.state,
43
42
  '',
@@ -45,21 +44,42 @@ describe('mapStateSync utilities', () => {
45
44
  )
46
45
  })
47
46
 
48
- it('uses fallback localhost URL (Line 17)', () => {
47
+ it('replaces existing map params when already present in the URL', () => {
48
+ setMapStateInURL('map1', { center: [10, 20], zoom: 5 }, 'http://test.com?map1:center=1,2&map1:zoom=3')
49
+ const url = globalThis.history.replaceState.mock.calls[0][2]
50
+ expect(url).toContain('map1:center=10,20')
51
+ expect(url).toContain('map1:zoom=5')
52
+ expect(url).not.toContain('map1:center=1,2')
53
+ })
54
+
55
+ it('preserves unrelated existing params (e.g. mv)', () => {
56
+ setMapStateInURL('map1', { center: [10, 20], zoom: 5 }, 'http://test.com/path?mv=map1')
57
+ const url = globalThis.history.replaceState.mock.calls[0][2]
58
+ expect(url).toContain('mv=map1')
59
+ expect(url).toContain('map1:center=10,20')
60
+ })
61
+
62
+ it('uses fallback localhost URL when href is null', () => {
49
63
  setMapStateInURL('map1', { zoom: 10 }, null)
50
- const lastCall = globalThis.history.replaceState.mock.calls[0][2]
51
- expect(lastCall).toContain('http://localhost')
64
+ const url = globalThis.history.replaceState.mock.calls[0][2]
65
+ expect(url).toContain('http://localhost')
52
66
  })
53
67
 
54
- it('handles state where zoom is nullish (Line 24)', () => {
68
+ it('omits zoom when zoom is null', () => {
55
69
  setMapStateInURL('map1', { center: [0, 0], zoom: null }, 'http://test.com')
56
- const lastCall = globalThis.history.replaceState.mock.calls[0][2]
57
- expect(lastCall).not.toContain('zoom')
70
+ const url = globalThis.history.replaceState.mock.calls[0][2]
71
+ expect(url).not.toContain('zoom')
58
72
  })
59
73
 
60
- it('triggers the default href parameter', () => {
74
+ it('uses window.location.href when no href is provided', () => {
61
75
  setMapStateInURL('map1', { zoom: 10 })
62
76
  expect(globalThis.history.replaceState).toHaveBeenCalled()
63
77
  })
78
+
79
+ it('produces a URL with no search string when state is empty and no existing params', () => {
80
+ setMapStateInURL('map1', {}, 'http://test.com/path')
81
+ const url = globalThis.history.replaceState.mock.calls[0][2]
82
+ expect(url).toBe('http://test.com/path')
83
+ })
64
84
  })
65
85
  })
@@ -1,104 +0,0 @@
1
- /* Custom styles for the Interactive Map docs site */
2
-
3
- .app-section {
4
- padding: 40px 0;
5
- }
6
-
7
- .govuk-template__body.app-homepage .govuk-template__body-inner {
8
- background-color: #f4f8fb;
9
- }
10
-
11
- .app-section {
12
- background-color: #ffffff;
13
- }
14
-
15
- .app-section--features {
16
- background-color: #f4f8fb;
17
- border-bottom: 1px solid #8eb8dc;
18
- }
19
-
20
- .app-prose-scope .app-section--features p:not(.app-no-prose *) {
21
- margin-bottom: 0;
22
- }
23
-
24
- .app-section--features .govuk-grid-column-one-third:not(:first-child) {
25
- margin-top: 30px;
26
-
27
- @media (min-width: 48.125em) {
28
- margin-top: 0;
29
- }
30
- }
31
-
32
- /* Hero image */
33
- .govuk-template--rebranded .app-masthead .govuk-grid-row {
34
- @media (min-width: 48.125em) {
35
- display: flex;
36
- }
37
- }
38
-
39
- .govuk-template--rebranded .app-masthead .govuk-grid-column-one-third-from-desktop {
40
- @media (min-width: 48.125em) {
41
- background-image: url('/images/hero.png');
42
- background-repeat: no-repeat;
43
- background-position: center bottom;
44
- background-size: 200px;
45
- }
46
- }
47
-
48
- /* Black */
49
- /* .govuk-template--rebranded .govuk-service-navigation:not(.govuk-service-navigation--inverse) .govuk-service-navigation__list__link,
50
- .govuk-template--rebranded .govuk-service-navigation:not(.govuk-service-navigation--inverse) .govuk-service-navigation__toggle {
51
- color: #0b0c0c !important;
52
- }
53
-
54
- .govuk-template--rebranded .govuk-service-navigation:not(.govuk-service-navigation--inverse) .govuk-service-navigation__list__item--active {
55
- border-color: #0b0c0c !important;
56
- } */
57
-
58
- /* Color 100% */
59
- .govuk-template--rebranded .app-masthead .govuk-button--inverse,
60
- .govuk-template--rebranded .app-masthead .govuk-button--inverse:visited,
61
- .govuk-template--rebranded .app-masthead .govuk-button--inverse:active,
62
- .govuk-template--rebranded .app-masthead .govuk-button--inverse:hover {
63
- color: #008531;
64
- }
65
-
66
- /* Background colour 100% */
67
- .govuk-template--rebranded .govuk-header__link--homepage:not(:focus),
68
- .govuk-template--rebranded .govuk-header,
69
- .govuk-template--rebranded .govuk-service-navigation.govuk-service-navigation--inverse,
70
- .govuk-template--rebranded .app-masthead {
71
- background-color: #008531 !important;
72
- }
73
-
74
- /* Box shadow colur #083d29 */
75
- .govuk-template--rebranded .app-masthead .govuk-button--inverse {
76
- box-shadow: 0 2px 0 #0b5c3e;
77
- }
78
-
79
- /* Background colour 4% tint */
80
- /* .govuk-template--rebranded .app-homepage .app-section.app-section--features,
81
- .govuk-template--rebranded .govuk-template__body.app-homepage .govuk-template__body-inner,
82
- .govuk-template--rebranded .govuk-service-navigation,
83
- .govuk-template--rebranded .govuk-footer {
84
- background-color: #f5faf7 !important;
85
- } */
86
-
87
- /* Border colour 100% */
88
- /* .govuk-template--rebranded .govuk-footer {
89
- border-color: #008531 !important;
90
- } */
91
-
92
- /* Border colour 30% tint */
93
- /* .govuk-template--rebranded .app-homepage .app-section.app-section--features,
94
- .govuk-template--rebranded .govuk-service-navigation {
95
- border-color: #b3dac1 !important;
96
- } */
97
-
98
- /* Border colour 50% tint */
99
- .govuk-template--rebranded .app-homepage .govuk-service-navigation--inverse .govuk-width-container {
100
- border-color: #66B683 !important;
101
- }
102
-
103
-
104
- /* #8eb8dc */
package/docs/examples.mdx DELETED
@@ -1,70 +0,0 @@
1
- import DemoMapInline from '../demo/DemoMapInline.js'
2
- import DemoMapButton from '../demo/DemoMapButton.js'
3
-
4
- # Examples
5
-
6
- See [Getting started](getting-started) for installation and full configuration options.
7
-
8
- ## Inline map
9
-
10
- Embed an interactive map directly on the page, allowing users to explore and interact with the map without leaving the current context.
11
-
12
- <DemoMapInline />
13
-
14
- ```js
15
- import InteractiveMap from '@defra/interactive-map'
16
- import maplibreProvider from '@defra/interactive-map/providers/maplibre'
17
- import searchPlugin from '@defra/interactive-map/plugins/search'
18
- import scaleBarPlugin from '@defra/interactive-map/plugins/scale-bar'
19
- import mapStylesPlugin from '@defra/interactive-map/plugins/map-styles'
20
-
21
- new InteractiveMap('my-map', {
22
- behaviour: 'inline',
23
- mapProvider: maplibreProvider(),
24
- mapStyle: {
25
- url: '/assets/my-map-style.json',
26
- attribution: '© OpenStreetMap contributors'
27
- },
28
- center: [-1.6, 53.1],
29
- zoom: 6,
30
- containerHeight: '500px',
31
- enableZoomControls: true,
32
- plugins: [
33
- searchPlugin({ customDatasets: [nominatimDataset], showMarker: true }),
34
- scaleBarPlugin({ units: 'metric' }),
35
- mapStylesPlugin({ mapStyles: [...] })
36
- ]
37
- })
38
- ```
39
-
40
- ## Button-triggered map
41
-
42
- Trigger the map to show on button press, allowing users to access the map when needed without it taking up space on the page by default.
43
-
44
- <DemoMapButton />
45
-
46
- ```js
47
- import InteractiveMap from '@defra/interactive-map'
48
- import maplibreProvider from '@defra/interactive-map/providers/maplibre'
49
- import searchPlugin from '@defra/interactive-map/plugins/search'
50
- import scaleBarPlugin from '@defra/interactive-map/plugins/scale-bar'
51
- import mapStylesPlugin from '@defra/interactive-map/plugins/map-styles'
52
-
53
- new InteractiveMap('my-map', {
54
- behaviour: 'buttonFirst',
55
- mapProvider: maplibreProvider(),
56
- mapStyle: {
57
- url: '/assets/my-map-style.json',
58
- attribution: '© OpenStreetMap contributors'
59
- },
60
- center: [-1.6, 53.1],
61
- zoom: 6,
62
- containerHeight: '500px',
63
- enableZoomControls: true,
64
- plugins: [
65
- searchPlugin({ customDatasets: [nominatimDataset], showMarker: true }),
66
- scaleBarPlugin({ units: 'metric' }),
67
- mapStylesPlugin({ mapStyles: [...] })
68
- ]
69
- })
70
- ```
File without changes