@defra/forms-engine-plugin 4.0.41 → 4.0.42

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 (90) hide show
  1. package/.public/javascripts/application.min.js +1 -1
  2. package/.public/javascripts/application.min.js.map +1 -1
  3. package/.public/javascripts/shared.min.js +1 -1
  4. package/.public/javascripts/shared.min.js.map +1 -1
  5. package/.public/stylesheets/application.min.css +2 -2
  6. package/.public/stylesheets/application.min.css.map +1 -1
  7. package/.server/client/javascripts/location-map.d.ts +93 -0
  8. package/.server/client/javascripts/location-map.js +745 -0
  9. package/.server/client/javascripts/location-map.js.map +1 -0
  10. package/.server/client/javascripts/shared.d.ts +4 -0
  11. package/.server/client/javascripts/shared.js +5 -0
  12. package/.server/client/javascripts/shared.js.map +1 -1
  13. package/.server/client/stylesheets/_location-fields.scss +11 -0
  14. package/.server/client/stylesheets/application.scss +0 -1
  15. package/.server/client/stylesheets/shared.scss +1 -0
  16. package/.server/config/index.js +1 -1
  17. package/.server/config/index.js.map +1 -1
  18. package/.server/server/plugins/engine/plugin.js +11 -0
  19. package/.server/server/plugins/engine/plugin.js.map +1 -1
  20. package/.server/server/plugins/engine/views/components/_location-field-base.html +1 -1
  21. package/.server/server/plugins/engine/views/components/osgridreffield.html +4 -2
  22. package/.server/server/plugins/map/index.d.ts +7 -0
  23. package/.server/server/plugins/map/index.js +20 -0
  24. package/.server/server/plugins/map/index.js.map +1 -0
  25. package/.server/server/plugins/map/routes/index.d.ts +20 -0
  26. package/.server/server/plugins/map/routes/index.js +128 -0
  27. package/.server/server/plugins/map/routes/index.js.map +1 -0
  28. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark.json +690 -0
  29. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark.png +0 -0
  30. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark@2x.json +690 -0
  31. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark@2x.png +0 -0
  32. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale.json +690 -0
  33. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale.png +0 -0
  34. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale@2x.json +690 -0
  35. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale@2x.png +0 -0
  36. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite.json +690 -0
  37. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite.png +0 -0
  38. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite@2x.json +690 -0
  39. package/.server/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite@2x.png +0 -0
  40. package/.server/server/plugins/map/routes/vts/OS_VTS_3857_Black_and_White.json +7858 -0
  41. package/.server/server/plugins/map/routes/vts/OS_VTS_3857_Dark.json +7669 -0
  42. package/.server/server/plugins/map/routes/vts/OS_VTS_3857_Outdoor.json +7653 -0
  43. package/.server/server/plugins/map/routes/vts/README.md +5 -0
  44. package/.server/server/plugins/map/service.d.ts +14 -0
  45. package/.server/server/plugins/map/service.js +76 -0
  46. package/.server/server/plugins/map/service.js.map +1 -0
  47. package/.server/server/plugins/map/service.test.js +120 -0
  48. package/.server/server/plugins/map/service.test.js.map +1 -0
  49. package/.server/server/plugins/map/test/__stubs__/find.d.ts +3 -0
  50. package/.server/server/plugins/map/test/__stubs__/find.js +216 -0
  51. package/.server/server/plugins/map/test/__stubs__/find.js.map +1 -0
  52. package/.server/server/plugins/map/test/__stubs__/nearest.d.ts +37 -0
  53. package/.server/server/plugins/map/test/__stubs__/nearest.js +38 -0
  54. package/.server/server/plugins/map/test/__stubs__/nearest.js.map +1 -0
  55. package/.server/server/plugins/map/types.d.ts +232 -0
  56. package/.server/server/plugins/map/types.js +120 -0
  57. package/.server/server/plugins/map/types.js.map +1 -0
  58. package/package.json +3 -1
  59. package/src/client/javascripts/location-map.js +766 -0
  60. package/src/client/javascripts/shared.js +4 -0
  61. package/src/client/stylesheets/_location-fields.scss +11 -0
  62. package/src/client/stylesheets/application.scss +0 -1
  63. package/src/client/stylesheets/shared.scss +1 -0
  64. package/src/config/index.ts +1 -1
  65. package/src/server/plugins/engine/plugin.ts +11 -0
  66. package/src/server/plugins/engine/views/components/_location-field-base.html +1 -1
  67. package/src/server/plugins/engine/views/components/osgridreffield.html +4 -2
  68. package/src/server/plugins/map/index.js +19 -0
  69. package/src/server/plugins/map/routes/index.js +146 -0
  70. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark.json +690 -0
  71. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark.png +0 -0
  72. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark@2x.json +690 -0
  73. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/dark@2x.png +0 -0
  74. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale.json +690 -0
  75. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale.png +0 -0
  76. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale@2x.json +690 -0
  77. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/greyscale@2x.png +0 -0
  78. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite.json +690 -0
  79. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite.png +0 -0
  80. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite@2x.json +690 -0
  81. package/src/server/plugins/map/routes/vts/OS_VTS_3857/resources/sprites/sprite@2x.png +0 -0
  82. package/src/server/plugins/map/routes/vts/OS_VTS_3857_Black_and_White.json +7858 -0
  83. package/src/server/plugins/map/routes/vts/OS_VTS_3857_Dark.json +7669 -0
  84. package/src/server/plugins/map/routes/vts/OS_VTS_3857_Outdoor.json +7653 -0
  85. package/src/server/plugins/map/routes/vts/README.md +5 -0
  86. package/src/server/plugins/map/service.js +84 -0
  87. package/src/server/plugins/map/service.test.js +144 -0
  88. package/src/server/plugins/map/test/__stubs__/find.js +271 -0
  89. package/src/server/plugins/map/test/__stubs__/nearest.js +46 -0
  90. package/src/server/plugins/map/types.js +119 -0
@@ -0,0 +1,766 @@
1
+ // @ts-expect-error - no types
2
+ import OsGridRef, { LatLon } from 'geodesy/osgridref.js'
3
+
4
+ /**
5
+ * Converts lat long to easting and northing
6
+ * @param {object} param
7
+ * @param {number} param.lat
8
+ * @param {number} param.long
9
+ * @returns {{ easting: number, northing: number }}
10
+ */
11
+ function latLongToEastingNorthing({ lat, long }) {
12
+ const point = new LatLon(lat, long)
13
+
14
+ return point.toOsGrid()
15
+ }
16
+
17
+ /**
18
+ * Converts easting and northing to lat long
19
+ * @param {object} param
20
+ * @param {number} param.easting
21
+ * @param {number} param.northing
22
+ * @returns {{ lat: number, long: number }}
23
+ */
24
+ function eastingNorthingToLatLong({ easting, northing }) {
25
+ const point = new OsGridRef(easting, northing)
26
+ const latLong = point.toLatLon()
27
+
28
+ return { lat: latLong.latitude, long: latLong.longitude }
29
+ }
30
+
31
+ /**
32
+ * Converts lat long to an ordnance survey grid reference
33
+ * @param {object} param
34
+ * @param {number} param.lat
35
+ * @param {number} param.long
36
+ * @returns {string}
37
+ */
38
+ function latLongToOsGridRef({ lat, long }) {
39
+ const point = new LatLon(lat, long)
40
+
41
+ return point.toOsGrid().toString()
42
+ }
43
+
44
+ /**
45
+ * Converts an ordnance survey grid reference to lat long
46
+ * @param {string} osGridRef
47
+ * @returns {{ lat: number, long: number }}
48
+ */
49
+ function osGridRefToLatLong(osGridRef) {
50
+ const point = OsGridRef.parse(osGridRef)
51
+ const latLong = point.toLatLon()
52
+
53
+ return { lat: latLong.latitude, long: latLong.longitude }
54
+ }
55
+
56
+ // Center of UK
57
+ const DEFAULT_LAT = 53.825564
58
+ const DEFAULT_LONG = -2.421975
59
+
60
+ /** @type {InteractiveMapInitConfig} */
61
+ const defaultConfig = {
62
+ zoom: '6',
63
+ center: [DEFAULT_LONG, DEFAULT_LAT]
64
+ }
65
+
66
+ const COMPANY_SYMBOL_CODE = 169
67
+ const LOCATION_FIELD_SELECTOR = 'input.govuk-input'
68
+ const EVENTS = {
69
+ interactMarkerChange: 'interact:markerchange'
70
+ }
71
+
72
+ const defaultData = {
73
+ VTS_OUTDOOR_URL: '/api/maps/vts/OS_VTS_3857_Outdoor.json',
74
+ VTS_DARK_URL: '/api/maps/vts/OS_VTS_3857_Dark.json',
75
+ VTS_BLACK_AND_WHITE_URL: '/api/maps/vts/OS_VTS_3857_Black_and_White.json'
76
+ }
77
+
78
+ /**
79
+ * Make a form submit handler that only allows submissions from allowed buttons
80
+ * @param {HTMLButtonElement[]} buttons - the form buttons to allow submissions
81
+ */
82
+ export function formSubmitFactory(buttons) {
83
+ /**
84
+ * The submit handler
85
+ * @param {SubmitEvent} e
86
+ */
87
+ const onFormSubmit = function (e) {
88
+ if (
89
+ !(e.submitter instanceof HTMLButtonElement) ||
90
+ !buttons.includes(e.submitter)
91
+ ) {
92
+ e.preventDefault()
93
+ }
94
+ }
95
+
96
+ return onFormSubmit
97
+ }
98
+
99
+ /**
100
+ * Initialise location maps
101
+ * @param {Partial<MapsEnvironmentConfig>} config - the map configuration
102
+ */
103
+ export function initMaps(config = {}) {
104
+ const {
105
+ assetPath = '/assets',
106
+ apiPath = '/form/api',
107
+ data = defaultData
108
+ } = config
109
+ const locations = document.querySelectorAll('.app-location-field')
110
+
111
+ // TODO: Fix this in `interactive-map`
112
+ // If there are location components on the page fix up the main form submit
113
+ // handler so it doesn't fire when using the integrated map search feature
114
+ if (locations.length) {
115
+ const form = document.querySelector('form')
116
+
117
+ if (form === null) {
118
+ return
119
+ }
120
+
121
+ const buttons = Array.from(form.querySelectorAll('button'))
122
+ form.addEventListener('submit', formSubmitFactory(buttons), false)
123
+ }
124
+
125
+ locations.forEach((location, index) => {
126
+ processLocation({ assetPath, apiPath, data }, location, index)
127
+ })
128
+ }
129
+
130
+ /**
131
+ * OS API request proxy factory
132
+ * @param {string} apiPath - the root API path
133
+ */
134
+ export function makeTileRequestTransformer(apiPath) {
135
+ /**
136
+ * Proxy OS API requests via our server
137
+ * @param {string} url - the request URL
138
+ * @param {string} resourceType - the resource type
139
+ */
140
+ return function transformTileRequest(url, resourceType) {
141
+ // Only proxy OS API requests that don't already have a key
142
+ if (resourceType !== 'Style' && url.startsWith('https://api.os.uk')) {
143
+ const urlObj = new URL(url)
144
+ if (!urlObj.searchParams.has('key')) {
145
+ return {
146
+ url: `${apiPath}/map-proxy?url=${encodeURIComponent(url)}`,
147
+ headers: {}
148
+ }
149
+ }
150
+ }
151
+
152
+ const spritesPath =
153
+ 'https://raw.githubusercontent.com/OrdnanceSurvey/OS-Vector-Tile-API-Stylesheets/main'
154
+
155
+ // Proxy sprite requests
156
+ if (url.startsWith(spritesPath)) {
157
+ const path = url.substring(spritesPath.length)
158
+ return {
159
+ url: `${apiPath}/maps/vts${path}`,
160
+ headers: {}
161
+ }
162
+ }
163
+
164
+ return { url, headers: {} }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Processes a location field to add map capability
170
+ * @param {MapsEnvironmentConfig} config - the location field element
171
+ * @param {Element} location - the location field element
172
+ * @param {*} index - the 0-based index
173
+ */
174
+ function processLocation(config, location, index) {
175
+ if (!(location instanceof HTMLDivElement)) {
176
+ return
177
+ }
178
+
179
+ const locationInputs = location.querySelector('.app-location-field-inputs')
180
+ if (!(locationInputs instanceof HTMLDivElement)) {
181
+ return
182
+ }
183
+ const locationType = location.dataset.locationtype
184
+
185
+ // Check for support
186
+ const supportedLocations = [
187
+ 'latlongfield',
188
+ 'eastingnorthingfield',
189
+ 'osgridreffield'
190
+ ]
191
+ if (!locationType || !supportedLocations.includes(locationType)) {
192
+ return
193
+ }
194
+
195
+ const mapContainer = document.createElement('div')
196
+ const mapId = `map_${index}`
197
+
198
+ mapContainer.setAttribute('id', mapId)
199
+ mapContainer.setAttribute('class', 'map-container')
200
+
201
+ const initConfig = getInitMapConfig(location) ?? defaultConfig
202
+
203
+ locationInputs.after(mapContainer)
204
+
205
+ const { map, interactPlugin } = createMap(mapId, initConfig, config)
206
+
207
+ map.on(
208
+ 'map:ready',
209
+ /**
210
+ * Callback function which fires when the map is ready
211
+ * @param {object} e - the event
212
+ * @param {MapLibreMap} e.map - the map provider instance
213
+ */
214
+ function onMapReady(e) {
215
+ switch (locationType) {
216
+ case 'latlongfield':
217
+ bindLatLongField(location, map, e.map)
218
+ break
219
+ case 'eastingnorthingfield':
220
+ bindEastingNorthingField(location, map, e.map)
221
+ break
222
+ case 'osgridreffield':
223
+ bindOsGridRefField(location, map, e.map)
224
+ break
225
+ default:
226
+ throw new Error('Not implemented')
227
+ }
228
+
229
+ // Add info panel
230
+ map.addPanel('info', {
231
+ showLabel: true,
232
+ label: 'How to use the map',
233
+ mobile: {
234
+ slot: 'bottom',
235
+ initiallyOpen: true,
236
+ dismissable: true,
237
+ modal: false
238
+ },
239
+ tablet: {
240
+ slot: 'bottom',
241
+ initiallyOpen: true,
242
+ dismissable: true,
243
+ modal: false
244
+ },
245
+ desktop: {
246
+ slot: 'bottom',
247
+ initiallyOpen: true,
248
+ dismissable: true,
249
+ modal: false
250
+ },
251
+ html: 'If using a map click on a point to update the location.<br><br>If using a keyboard, navigate to the point, centering the crosshair at the location and press enter.'
252
+ })
253
+
254
+ // Enable the interact plugin
255
+ interactPlugin.enable()
256
+ }
257
+ )
258
+ }
259
+
260
+ /**
261
+ * Create a Defra map instance
262
+ * @param {string} mapId - the map id
263
+ * @param {InteractiveMapInitConfig} initConfig - the map initial configuration
264
+ * @param {MapsEnvironmentConfig} mapsConfig - the map environment params
265
+ */
266
+ function createMap(mapId, initConfig, mapsConfig) {
267
+ const { assetPath, apiPath, data = defaultData } = mapsConfig
268
+ const logoAltText = 'Ordnance survey logo'
269
+
270
+ // @ts-expect-error - Defra namespace currently comes from UMD support files
271
+ const defra = window.defra
272
+
273
+ const interactPlugin = defra.interactPlugin({
274
+ dataLayers: [],
275
+ markerColor: { outdoor: '#ff0000', dark: '#00ff00' },
276
+ interactionMode: 'marker',
277
+ multiSelect: false
278
+ })
279
+
280
+ /** @type {InteractiveMap} */
281
+ const map = new defra.InteractiveMap(mapId, {
282
+ ...initConfig,
283
+ mapProvider: defra.maplibreProvider(),
284
+ reverseGeocodeProvider: defra.openNamesProvider({
285
+ url: `${apiPath}/reverse-geocode-proxy?easting={easting}&northing={northing}`
286
+ }),
287
+ behaviour: 'inline',
288
+ minZoom: 6,
289
+ maxZoom: 18,
290
+ containerHeight: '400px',
291
+ enableZoomControls: true,
292
+ transformRequest: makeTileRequestTransformer(apiPath),
293
+ plugins: [
294
+ defra.mapStylesPlugin({
295
+ mapStyles: [
296
+ {
297
+ id: 'outdoor',
298
+ label: 'Outdoor',
299
+ url: data.VTS_OUTDOOR_URL,
300
+ thumbnail: `${assetPath}/interactive-map/assets/images/outdoor-map-thumb.jpg`,
301
+ logo: `${assetPath}/interactive-map/assets/images/os-logo.svg`,
302
+ logoAltText,
303
+ attribution: `Contains OS data ${String.fromCodePoint(COMPANY_SYMBOL_CODE)} Crown copyright and database rights ${new Date().getFullYear()}`,
304
+ backgroundColor: '#f5f5f0'
305
+ },
306
+ {
307
+ id: 'dark',
308
+ label: 'Dark',
309
+ url: data.VTS_DARK_URL,
310
+ mapColorScheme: 'dark',
311
+ appColorScheme: 'dark',
312
+ thumbnail: `${assetPath}/interactive-map/assets/images/dark-map-thumb.jpg`,
313
+ logo: `${assetPath}/interactive-map/assets/images/os-logo-white.svg`,
314
+ logoAltText,
315
+ attribution: `Contains OS data ${String.fromCodePoint(COMPANY_SYMBOL_CODE)} Crown copyright and database rights ${new Date().getFullYear()}`
316
+ },
317
+ {
318
+ id: 'black-and-white',
319
+ label: 'Black/White',
320
+ url: data.VTS_BLACK_AND_WHITE_URL,
321
+ thumbnail: `${assetPath}/interactive-map/assets/images/black-and-white-map-thumb.jpg`,
322
+ logo: `${assetPath}/interactive-map/assets/images/os-logo-black.svg`,
323
+ logoAltText,
324
+ attribution: `Contains OS data ${String.fromCodePoint(COMPANY_SYMBOL_CODE)} Crown copyright and database rights ${new Date().getFullYear()}`
325
+ }
326
+ ]
327
+ }),
328
+ interactPlugin,
329
+ defra.searchPlugin({
330
+ osNamesURL: `${apiPath}/geocode-proxy?query={query}`,
331
+ width: '300px',
332
+ showMarker: false
333
+ }),
334
+ defra.scaleBarPlugin({
335
+ units: 'metric'
336
+ })
337
+ ]
338
+ })
339
+
340
+ return { map, interactPlugin }
341
+ }
342
+
343
+ /**
344
+ * Gets initial map config for a location field
345
+ * @param {HTMLDivElement} locationField - the location field element
346
+ */
347
+ function getInitMapConfig(locationField) {
348
+ const locationType = locationField.dataset.locationtype
349
+
350
+ switch (locationType) {
351
+ case 'latlongfield':
352
+ return getInitLatLongMapConfig(locationField)
353
+ case 'eastingnorthingfield':
354
+ return getInitEastingNorthingMapConfig(locationField)
355
+ case 'osgridreffield':
356
+ return getInitOsGridRefMapConfig(locationField)
357
+ default:
358
+ throw new Error('Not implemented')
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Validates lat and long is numeric and within UK bounds
364
+ * @param {string} strLat - the latitude string
365
+ * @param {string} strLong - the longitude string
366
+ * @returns {{ valid: false } | { valid: true, value: { lat: number, long: number } }}
367
+ */
368
+ function validateLatLong(strLat, strLong) {
369
+ const lat = strLat.trim() && Number(strLat.trim())
370
+ const long = strLong.trim() && Number(strLong.trim())
371
+
372
+ if (!lat || !long) {
373
+ return { valid: false }
374
+ }
375
+
376
+ const latMin = 49.85
377
+ const latMax = 60.859
378
+ const longMin = -13.687
379
+ const longMax = 1.767
380
+
381
+ const latInBounds = lat >= latMin && lat <= latMax
382
+ const longInBounds = long >= longMin && long <= longMax
383
+
384
+ if (!latInBounds || !longInBounds) {
385
+ return { valid: false }
386
+ }
387
+
388
+ return { valid: true, value: { lat, long } }
389
+ }
390
+
391
+ /**
392
+ * Validates easting and northing is numeric and within UK bounds
393
+ * @param {string} strEasting - the easting string
394
+ * @param {string} strNorthing - the northing string
395
+ * @returns {{ valid: false } | { valid: true, value: { easting: number, northing: number } }}
396
+ */
397
+ function validateEastingNorthing(strEasting, strNorthing) {
398
+ const easting = strEasting.trim() && Number(strEasting.trim())
399
+ const northing = strNorthing.trim() && Number(strNorthing.trim())
400
+
401
+ if (!easting || !northing) {
402
+ return { valid: false }
403
+ }
404
+
405
+ const eastingMin = 0
406
+ const eastingMax = 700000
407
+ const northingMin = 0
408
+ const northingMax = 1300000
409
+
410
+ const latInBounds = easting >= eastingMin && easting <= eastingMax
411
+ const longInBounds = northing >= northingMin && northing <= northingMax
412
+
413
+ if (!latInBounds || !longInBounds) {
414
+ return { valid: false }
415
+ }
416
+
417
+ return { valid: true, value: { easting, northing } }
418
+ }
419
+
420
+ /**
421
+ * Validates OS grid reference is correct
422
+ * @param {string} osGridRef - the OsGridRef
423
+ * @returns {{ valid: false } | { valid: true, value: string }}
424
+ */
425
+ function validateOsGridRef(osGridRef) {
426
+ if (!osGridRef) {
427
+ return { valid: false }
428
+ }
429
+
430
+ const pattern =
431
+ /^((([sS]|[nN])[a-hA-Hj-zJ-Z])|(([tT]|[oO])[abfglmqrvwABFGLMQRVW])|([hH][l-zL-Z])|([jJ][lmqrvwLMQRVW]))\s?(([0-9]{3})\s?([0-9]{3})|([0-9]{4})\s?([0-9]{4})|([0-9]{5})\s?([0-9]{5}))$/
432
+
433
+ const match = pattern.exec(osGridRef)
434
+
435
+ if (match === null) {
436
+ return { valid: false }
437
+ }
438
+
439
+ return { valid: true, value: match[0] }
440
+ }
441
+
442
+ /**
443
+ * Gets the inputs for a latlong location field
444
+ * @param {HTMLDivElement} locationField - the latlong location field element
445
+ */
446
+ function getLatLongInputs(locationField) {
447
+ const inputs = locationField.querySelectorAll(LOCATION_FIELD_SELECTOR)
448
+
449
+ if (inputs.length !== 2) {
450
+ throw new Error('Expected 2 inputs for lat and long')
451
+ }
452
+
453
+ const latInput = /** @type {HTMLInputElement} */ (inputs[0])
454
+ const longInput = /** @type {HTMLInputElement} */ (inputs[1])
455
+
456
+ return { latInput, longInput }
457
+ }
458
+
459
+ /**
460
+ * Gets the inputs for a easting/northing location field
461
+ * @param {HTMLDivElement} locationField - the eastingnorthing location field element
462
+ */
463
+ function getEastingNorthingInputs(locationField) {
464
+ const inputs = locationField.querySelectorAll(LOCATION_FIELD_SELECTOR)
465
+
466
+ if (inputs.length !== 2) {
467
+ throw new Error('Expected 2 inputs for easting and northing')
468
+ }
469
+
470
+ const eastingInput = /** @type {HTMLInputElement} */ (inputs[0])
471
+ const northingInput = /** @type {HTMLInputElement} */ (inputs[1])
472
+
473
+ return { eastingInput, northingInput }
474
+ }
475
+
476
+ /**
477
+ * Gets the input for a OS grid reference location field
478
+ * @param {HTMLDivElement} locationField - the osgridref location field element
479
+ */
480
+ function getOsGridRefInput(locationField) {
481
+ const input = locationField.querySelector(LOCATION_FIELD_SELECTOR)
482
+
483
+ if (input === null) {
484
+ throw new Error('Expected 1 input for osgridref')
485
+ }
486
+
487
+ return /** @type {HTMLInputElement} */ (input)
488
+ }
489
+
490
+ /**
491
+ * Get the initial map config for a center point
492
+ * @param {MapCenter} center - the point
493
+ */
494
+ function getInitMapCenterConfig(center) {
495
+ return {
496
+ zoom: '16',
497
+ center,
498
+ markers: [
499
+ {
500
+ id: 'location',
501
+ coords: center
502
+ }
503
+ ]
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Gets initial map config for a latlong location field
509
+ * @param {HTMLDivElement} locationField - the latlong location field element
510
+ * @returns {InteractiveMapInitConfig | undefined}
511
+ */
512
+ function getInitLatLongMapConfig(locationField) {
513
+ const { latInput, longInput } = getLatLongInputs(locationField)
514
+ const result = validateLatLong(latInput.value, longInput.value)
515
+
516
+ if (!result.valid) {
517
+ return undefined
518
+ }
519
+
520
+ /** @type {MapCenter} */
521
+ const center = [result.value.long, result.value.lat]
522
+
523
+ return getInitMapCenterConfig(center)
524
+ }
525
+
526
+ /**
527
+ * Gets initial map config for a easting/northing location field
528
+ * @param {HTMLDivElement} locationField - the eastingnorthing location field element
529
+ * @returns {InteractiveMapInitConfig | undefined}
530
+ */
531
+ function getInitEastingNorthingMapConfig(locationField) {
532
+ const { eastingInput, northingInput } =
533
+ getEastingNorthingInputs(locationField)
534
+ const result = validateEastingNorthing(
535
+ eastingInput.value,
536
+ northingInput.value
537
+ )
538
+
539
+ if (!result.valid) {
540
+ return undefined
541
+ }
542
+
543
+ const latlong = eastingNorthingToLatLong(result.value)
544
+
545
+ /** @type {MapCenter} */
546
+ const center = [latlong.long, latlong.lat]
547
+
548
+ return getInitMapCenterConfig(center)
549
+ }
550
+
551
+ /**
552
+ * Gets initial map config for an OS grid reference location field
553
+ * @param {HTMLDivElement} locationField - the osgridref location field element
554
+ * @returns {InteractiveMapInitConfig | undefined}
555
+ */
556
+ function getInitOsGridRefMapConfig(locationField) {
557
+ const osGridRefInput = getOsGridRefInput(locationField)
558
+ const result = validateOsGridRef(osGridRefInput.value)
559
+
560
+ if (!result.valid) {
561
+ return undefined
562
+ }
563
+
564
+ const latlong = osGridRefToLatLong(result.value)
565
+
566
+ /** @type {MapCenter} */
567
+ const center = [latlong.long, latlong.lat]
568
+
569
+ return getInitMapCenterConfig(center)
570
+ }
571
+
572
+ /**
573
+ * Bind a latlong field to the map
574
+ * @param {HTMLDivElement} locationField - the latlong location field
575
+ * @param {InteractiveMap} map - the map component instance (of InteractiveMap)
576
+ * @param {MapLibreMap} mapProvider - the map provider instance (of MapLibreMap)
577
+ */
578
+ function bindLatLongField(locationField, map, mapProvider) {
579
+ const { latInput, longInput } = getLatLongInputs(locationField)
580
+
581
+ map.on(
582
+ EVENTS.interactMarkerChange,
583
+ /**
584
+ * Callback function which fires when the map marker changes
585
+ * @param {object} e - the event
586
+ * @param {[number, number]} e.coords - the map marker coordinates
587
+ */
588
+ function onInteractMarkerChange(e) {
589
+ const maxPrecision = 7
590
+ latInput.value = e.coords[1].toFixed(maxPrecision)
591
+ longInput.value = e.coords[0].toFixed(maxPrecision)
592
+ }
593
+ )
594
+
595
+ /**
596
+ * Lat & long input change event listener
597
+ * Update the map view location when the inputs are changed
598
+ */
599
+ function onUpdateInputs() {
600
+ const result = validateLatLong(latInput.value, longInput.value)
601
+
602
+ if (result.valid) {
603
+ /** @type {MapCenter} */
604
+ const center = [result.value.long, result.value.lat]
605
+
606
+ centerMap(map, mapProvider, center)
607
+ }
608
+ }
609
+
610
+ latInput.addEventListener('change', onUpdateInputs, false)
611
+ longInput.addEventListener('change', onUpdateInputs, false)
612
+ }
613
+
614
+ /**
615
+ * Bind an eastingnorthing field to the map
616
+ * @param {HTMLDivElement} locationField - the eastingnorthing location field
617
+ * @param {InteractiveMap} map - the map component instance (of InteractiveMap)
618
+ * @param {MapLibreMap} mapProvider - the map provider instance (of MapLibreMap)
619
+ */
620
+ function bindEastingNorthingField(locationField, map, mapProvider) {
621
+ const { eastingInput, northingInput } =
622
+ getEastingNorthingInputs(locationField)
623
+
624
+ map.on(
625
+ EVENTS.interactMarkerChange,
626
+ /**
627
+ * Callback function which fires when the map marker changes
628
+ * @param {object} e - the event
629
+ * @param {[number, number]} e.coords - the map marker coordinates
630
+ */
631
+ function onInteractMarkerChange(e) {
632
+ const maxPrecision = 0
633
+ const point = latLongToEastingNorthing({
634
+ lat: e.coords[1],
635
+ long: e.coords[0]
636
+ })
637
+
638
+ eastingInput.value = point.easting.toFixed(maxPrecision)
639
+ northingInput.value = point.northing.toFixed(maxPrecision)
640
+ }
641
+ )
642
+
643
+ /**
644
+ * Easting & northing input change event listener
645
+ * Update the map view location when the inputs are changed
646
+ */
647
+ function onUpdateInputs() {
648
+ const result = validateEastingNorthing(
649
+ eastingInput.value,
650
+ northingInput.value
651
+ )
652
+
653
+ if (result.valid) {
654
+ const latlong = eastingNorthingToLatLong(result.value)
655
+
656
+ /** @type {MapCenter} */
657
+ const center = [latlong.long, latlong.lat]
658
+
659
+ centerMap(map, mapProvider, center)
660
+ }
661
+ }
662
+
663
+ eastingInput.addEventListener('change', onUpdateInputs, false)
664
+ northingInput.addEventListener('change', onUpdateInputs, false)
665
+ }
666
+
667
+ /**
668
+ * Bind an OS grid reference field to the map
669
+ * @param {HTMLDivElement} locationField - the osgridref location field
670
+ * @param {InteractiveMap} map - the map component instance (of InteractiveMap)
671
+ * @param {MapLibreMap} mapProvider - the map provider instance (of MapLibreMap)
672
+ */
673
+ function bindOsGridRefField(locationField, map, mapProvider) {
674
+ const osGridRefInput = getOsGridRefInput(locationField)
675
+
676
+ map.on(
677
+ EVENTS.interactMarkerChange,
678
+ /**
679
+ * Callback function which fires when the map marker changes
680
+ * @param {object} e - the event
681
+ * @param {[number, number]} e.coords - the map marker coordinates
682
+ */
683
+ function onInteractMarkerChange(e) {
684
+ const point = latLongToOsGridRef({
685
+ lat: e.coords[1],
686
+ long: e.coords[0]
687
+ })
688
+
689
+ osGridRefInput.value = point
690
+ }
691
+ )
692
+
693
+ /**
694
+ * OS grid reference input change event listener
695
+ * Update the map view location when the input is changed
696
+ */
697
+ function onUpdateInput() {
698
+ const result = validateOsGridRef(osGridRefInput.value)
699
+
700
+ if (result.valid) {
701
+ const latlong = osGridRefToLatLong(result.value)
702
+
703
+ /** @type {MapCenter} */
704
+ const center = [latlong.long, latlong.lat]
705
+
706
+ centerMap(map, mapProvider, center)
707
+ }
708
+ }
709
+
710
+ osGridRefInput.addEventListener('change', onUpdateInput, false)
711
+ }
712
+
713
+ /**
714
+ * Updates the marker position and moves the map view port the new location
715
+ * @param {InteractiveMap} map - the map component instance (of InteractiveMap)
716
+ * @param {MapLibreMap} mapProvider - the map provider instance (of MapLibreMap)
717
+ * @param {MapCenter} center - the point
718
+ */
719
+ function centerMap(map, mapProvider, center) {
720
+ // Move the 'location' marker to the new point
721
+ map.addMarker('location', center)
722
+
723
+ // Pan & zoom the map to the new valid location
724
+ mapProvider.flyTo({
725
+ center,
726
+ zoom: 14,
727
+ essential: true
728
+ })
729
+ }
730
+
731
+ /**
732
+ * @typedef {object} InteractiveMap - an instance of a InteractiveMap
733
+ * @property {Function} on - register callback listeners to map events
734
+ * @property {Function} addPanel - adds a new panel to the map
735
+ * @property {Function} addMarker - adds/updates a marker
736
+ */
737
+
738
+ /**
739
+ * @typedef {object} MapLibreMap
740
+ * @property {Function} flyTo - pans/zooms to a new location
741
+ */
742
+
743
+ /**
744
+ * @typedef {[number, number]} MapCenter - Map center point as [long, lat]
745
+ */
746
+
747
+ /**
748
+ * @typedef {object} InteractiveMapInitConfig - additional config that can be provided to InteractiveMap
749
+ * @property {string} zoom - the zoom level of the map
750
+ * @property {MapCenter} center - the center point of the map
751
+ * @property {{ id: string, coords: MapCenter}[]} [markers] - the markers to add to the map
752
+ */
753
+
754
+ /**
755
+ * @typedef {object} TileData
756
+ * @property {string} VTS_OUTDOOR_URL - the outdoor tile URL
757
+ * @property {string} VTS_DARK_URL - the dark tile URL
758
+ * @property {string} VTS_BLACK_AND_WHITE_URL - the black and white tile URL
759
+ */
760
+
761
+ /**
762
+ * @typedef {object} MapsEnvironmentConfig
763
+ * @property {string} assetPath - the root asset path
764
+ * @property {string} apiPath - the root API path
765
+ * @property {TileData} data - the tile data config
766
+ */