@defra/forms-engine-plugin 4.2.0 → 4.4.0

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 (98) hide show
  1. package/.public/javascripts/application.min.js.map +1 -1
  2. package/.public/javascripts/shared.min.js +1 -1
  3. package/.public/javascripts/shared.min.js.map +1 -1
  4. package/.public/javascripts/vendor/accessible-autocomplete.min.js.map +1 -1
  5. package/.public/stylesheets/application.min.css +1 -1
  6. package/.public/stylesheets/application.min.css.map +1 -1
  7. package/.server/client/javascripts/geospatial-map.d.ts +189 -0
  8. package/.server/client/javascripts/geospatial-map.js +1068 -0
  9. package/.server/client/javascripts/geospatial-map.js.map +1 -0
  10. package/.server/client/javascripts/location-map.d.ts +6 -91
  11. package/.server/client/javascripts/location-map.js +78 -385
  12. package/.server/client/javascripts/location-map.js.map +1 -1
  13. package/.server/client/javascripts/map.d.ts +199 -0
  14. package/.server/client/javascripts/map.js +384 -0
  15. package/.server/client/javascripts/map.js.map +1 -0
  16. package/.server/client/javascripts/shared.d.ts +3 -1
  17. package/.server/client/javascripts/shared.js +3 -1
  18. package/.server/client/javascripts/shared.js.map +1 -1
  19. package/.server/client/stylesheets/shared.scss +7 -0
  20. package/.server/server/plugins/engine/components/ComponentBase.d.ts +1 -0
  21. package/.server/server/plugins/engine/components/ComponentBase.js +2 -0
  22. package/.server/server/plugins/engine/components/ComponentBase.js.map +1 -1
  23. package/.server/server/plugins/engine/components/FormComponent.d.ts +9 -1
  24. package/.server/server/plugins/engine/components/FormComponent.js +22 -0
  25. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  26. package/.server/server/plugins/engine/components/GeospatialField.d.ts +77 -0
  27. package/.server/server/plugins/engine/components/GeospatialField.js +102 -0
  28. package/.server/server/plugins/engine/components/GeospatialField.js.map +1 -0
  29. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.d.ts +3 -0
  30. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.js +63 -0
  31. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.js.map +1 -0
  32. package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
  33. package/.server/server/plugins/engine/components/helpers/components.js +7 -0
  34. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  35. package/.server/server/plugins/engine/components/helpers/geospatial.d.ts +6 -0
  36. package/.server/server/plugins/engine/components/helpers/geospatial.js +71 -0
  37. package/.server/server/plugins/engine/components/helpers/geospatial.js.map +1 -0
  38. package/.server/server/plugins/engine/components/helpers/geospatial.test.js +42 -0
  39. package/.server/server/plugins/engine/components/helpers/geospatial.test.js.map +1 -0
  40. package/.server/server/plugins/engine/components/index.d.ts +1 -0
  41. package/.server/server/plugins/engine/components/index.js +1 -0
  42. package/.server/server/plugins/engine/components/index.js.map +1 -1
  43. package/.server/server/plugins/engine/models/FormModel.js +0 -4
  44. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  45. package/.server/server/plugins/engine/pageControllers/PageController.d.ts +1 -0
  46. package/.server/server/plugins/engine/pageControllers/PageController.js +2 -0
  47. package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
  48. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -4
  49. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  50. package/.server/server/plugins/engine/pageControllers/helpers/state.d.ts +8 -7
  51. package/.server/server/plugins/engine/pageControllers/helpers/state.js +39 -12
  52. package/.server/server/plugins/engine/pageControllers/helpers/state.js.map +1 -1
  53. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +13 -1
  54. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -1
  55. package/.server/server/plugins/engine/pageControllers/validationOptions.js +2 -1
  56. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  57. package/.server/server/plugins/engine/routes/index.js +8 -1
  58. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  59. package/.server/server/plugins/engine/types.d.ts +63 -2
  60. package/.server/server/plugins/engine/types.js +33 -0
  61. package/.server/server/plugins/engine/types.js.map +1 -1
  62. package/.server/server/plugins/engine/views/components/geospatialfield.html +7 -0
  63. package/.server/server/plugins/nunjucks/context.test.js.map +1 -1
  64. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  65. package/.server/server/routes/types.js.map +1 -1
  66. package/.server/server/services/cacheService.js +3 -0
  67. package/.server/server/services/cacheService.js.map +1 -1
  68. package/package.json +9 -5
  69. package/src/client/javascripts/geospatial-map.js +1023 -0
  70. package/src/client/javascripts/location-map.js +94 -390
  71. package/src/client/javascripts/map.js +389 -0
  72. package/src/client/javascripts/shared.js +3 -1
  73. package/src/client/stylesheets/shared.scss +7 -0
  74. package/src/server/plugins/engine/components/ComponentBase.ts +2 -0
  75. package/src/server/plugins/engine/components/FormComponent.ts +29 -0
  76. package/src/server/plugins/engine/components/GeospatialField.test.ts +380 -0
  77. package/src/server/plugins/engine/components/GeospatialField.ts +145 -0
  78. package/src/server/plugins/engine/components/helpers/__stubs__/geospatial.ts +85 -0
  79. package/src/server/plugins/engine/components/helpers/components.test.ts +44 -0
  80. package/src/server/plugins/engine/components/helpers/components.ts +10 -0
  81. package/src/server/plugins/engine/components/helpers/geospatial.test.js +55 -0
  82. package/src/server/plugins/engine/components/helpers/geospatial.ts +93 -0
  83. package/src/server/plugins/engine/components/index.ts +1 -0
  84. package/src/server/plugins/engine/models/FormModel.ts +0 -4
  85. package/src/server/plugins/engine/pageControllers/PageController.ts +2 -0
  86. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -6
  87. package/src/server/plugins/engine/pageControllers/helpers/state.test.ts +80 -16
  88. package/src/server/plugins/engine/pageControllers/helpers/state.ts +57 -17
  89. package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +74 -0
  90. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +17 -1
  91. package/src/server/plugins/engine/pageControllers/validationOptions.ts +3 -1
  92. package/src/server/plugins/engine/routes/index.test.ts +4 -2
  93. package/src/server/plugins/engine/routes/index.ts +13 -1
  94. package/src/server/plugins/engine/types.ts +77 -4
  95. package/src/server/plugins/engine/views/components/geospatialfield.html +7 -0
  96. package/src/server/plugins/nunjucks/context.test.js +2 -3
  97. package/src/server/routes/types.ts +4 -2
  98. package/src/server/services/cacheService.ts +2 -0
@@ -0,0 +1,1023 @@
1
+ import { bbox } from '@turf/bbox'
2
+
3
+ import {
4
+ EVENTS,
5
+ createMap,
6
+ defaultConfig,
7
+ getCentroidGridRef,
8
+ getCoordinateGridRef
9
+ } from '~/src/client/javascripts/map.js'
10
+
11
+ const helpPanelConfig = {
12
+ showLabel: true,
13
+ label: 'How to use this map',
14
+ mobile: {
15
+ slot: 'bottom',
16
+ open: true,
17
+ dismissible: true,
18
+ modal: false
19
+ },
20
+ tablet: {
21
+ slot: 'bottom',
22
+ open: true,
23
+ dismissible: true,
24
+ modal: false
25
+ },
26
+ desktop: {
27
+ slot: 'bottom',
28
+ open: true,
29
+ dismissible: true,
30
+ modal: false
31
+ },
32
+ html: '<p class="govuk-body-s govuk-!-margin-bottom-2">You can add points, shapes or lines to the map.</p><ul class="govuk-list govuk-list--number govuk-body-s"><li>Search for a county, place or postcode</li><li>Use the + and - icons to zoom in and out</li><li>Add a point or click \'Done\' when you have finished drawing a line or shape</li><li>Give the location a name</li></ul>'
33
+ }
34
+
35
+ const lineFeatureProperties = {
36
+ stroke: 'rgba(0, 11, 112, 1)',
37
+ fill: 'rgba(0, 11, 112, 0.2)',
38
+ strokeWidth: 2
39
+ }
40
+
41
+ const polygonFeatureProperties = {
42
+ stroke: 'rgba(0,112,60,1)',
43
+ fill: 'rgba(0,112,60,0.2)',
44
+ strokeWidth: 2
45
+ }
46
+
47
+ /**
48
+ * @type {Record<'Point' | 'LineString' | 'Polygon', string>}
49
+ */
50
+ const typeDescriptions = {
51
+ Point: 'Point',
52
+ LineString: 'Line',
53
+ Polygon: 'Shape'
54
+ }
55
+
56
+ const POINT_SVG =
57
+ '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" /><circle cx="12" cy="10" r="3" />'
58
+ const POLYGON_SVG =
59
+ '<path d="M19.5 7v10M4.5 7v10M7 19.5h10M7 4.5h10"/><path d="M22 18v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1zm0-15v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1zM7 18v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1zM7 3v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1z"/>'
60
+ const LINE_SVG =
61
+ '<path d="M5.706 16.294L16.294 5.706"/><path d="M21 2v3c0 .549-.451 1-1 1h-3c-.549 0-1-.451-1-1V2c0-.549.451-1 1-1h3c.549 0 1 .451 1 1zM6 17v3c0 .549-.451 1-1 1H2c-.549 0-1-.451-1-1v-3c0-.549.451-1 1-1h3c.549 0 1 .451 1 1z"/>'
62
+
63
+ /**
64
+ * Extract and parses the GeoJSON from the textarea
65
+ * @param {HTMLTextAreaElement} geospatialInput - the textarea containing the geojson
66
+ */
67
+ export function getGeoJSON(geospatialInput) {
68
+ const value = geospatialInput.value.trim()
69
+ const hasValue = !!value
70
+
71
+ /** @type {FeatureCollection} */
72
+ const features = hasValue ? JSON.parse(value) : []
73
+
74
+ /** @type {GeoJSON} */
75
+ const geojson = {
76
+ type: 'FeatureCollection',
77
+ features
78
+ }
79
+
80
+ return geojson
81
+ }
82
+
83
+ /**
84
+ * Gets the bounding box covering a feature collection
85
+ * @param {GeoJSON} geojson - the geojson
86
+ */
87
+ export function getBoundingBox(geojson) {
88
+ return bbox(geojson)
89
+ }
90
+
91
+ /**
92
+ * Processes a geospatial field to add map capability
93
+ * @param {MapsEnvironmentConfig} config - the geospatial field element
94
+ * @param {Element} geospatial - the geospatial field element
95
+ * @param {number} index - the 0-based index
96
+ */
97
+ export function processGeospatial(config, geospatial, index) {
98
+ // @ts-expect-error - Defra namespace currently comes from UMD support files
99
+ const defra = window.defra
100
+
101
+ if (!(geospatial instanceof HTMLDivElement)) {
102
+ return
103
+ }
104
+
105
+ const geospatialInput = geospatial.querySelector('.govuk-textarea')
106
+ if (!(geospatialInput instanceof HTMLTextAreaElement)) {
107
+ return
108
+ }
109
+
110
+ const { listEl, mapId } = createContainers(geospatialInput, index)
111
+ const geojson = getGeoJSON(geospatialInput)
112
+ const bounds = geojson.features.length ? getBoundingBox(geojson) : undefined
113
+ const drawPlugin = defra.drawMLPlugin()
114
+
115
+ const initConfig = {
116
+ ...defaultConfig,
117
+ bounds,
118
+ plugins: [drawPlugin]
119
+ }
120
+
121
+ const { map, interactPlugin } = createMap(mapId, initConfig, config)
122
+ const featuresManager = getFeaturesManager(geojson)
123
+ const activeFeatureManager = getActiveFeatureManager()
124
+ const uiManager = getUIManager(geojson, map, mapId, listEl, geospatialInput)
125
+
126
+ /**
127
+ * @type {Context}
128
+ */
129
+ const context = {
130
+ map,
131
+ featuresManager,
132
+ activeFeatureManager,
133
+ uiManager,
134
+ interactPlugin,
135
+ drawPlugin
136
+ }
137
+
138
+ addEventListeners(context)
139
+ }
140
+
141
+ /**
142
+ * Adds a feature to the map
143
+ * @param {Feature} feature - the geojson feature
144
+ * @param {any} drawPlugin - the map draw plugin
145
+ * @param {InteractiveMap} map - the interactive map
146
+ */
147
+ export function addFeatureToMap(feature, drawPlugin, map) {
148
+ switch (feature.geometry.type) {
149
+ case 'Polygon':
150
+ drawPlugin.addFeature({ ...feature, ...polygonFeatureProperties })
151
+ break
152
+ case 'LineString':
153
+ drawPlugin.addFeature({ ...feature, ...lineFeatureProperties })
154
+ break
155
+ case 'Point':
156
+ map.addMarker(feature.id, feature.geometry.coordinates)
157
+ break
158
+ default:
159
+ break
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Returns HTML summary list for the features
165
+ * @param {FeatureCollection} features - the features
166
+ * @param {string} mapId - the ID of the map
167
+ * @param {boolean} [readonly] - render the list in readonly mode
168
+ */
169
+ export function createFeaturesHTML(features, mapId, readonly = false) {
170
+ return `<dl class="govuk-summary-list">
171
+ ${features.map((feature, index) => createFeatureHTML(feature, index, mapId, readonly)).join('\n')}
172
+ </dl>`
173
+ }
174
+
175
+ /**
176
+ * Focus feature
177
+ * @param {Feature} feature - the feature
178
+ * @param {MapLibreMap} mapProvider - the feature id
179
+ */
180
+ export function focusFeature(feature, mapProvider) {
181
+ mapProvider.fitBounds(bbox(feature))
182
+ }
183
+
184
+ /**
185
+ * Returns HTML summary row for an single feature
186
+ * @param {Feature} feature - the geo feature
187
+ * @param {number} index - the feature index
188
+ * @param {string} mapId - the ID of the map
189
+ * @param {boolean} readonly - render the list item in readonly mode
190
+ */
191
+ function createFeatureHTML(feature, index, mapId, readonly) {
192
+ const flattened = feature.geometry.coordinates.flat(2)
193
+
194
+ const points = []
195
+ for (let i = 0; i < flattened.length; i += 2) {
196
+ points.push(flattened.slice(i, i + 2).join(', '))
197
+ }
198
+ const coordinates = points.map((p) => `<li>${p}</li>`).join('')
199
+
200
+ const description = readonly
201
+ ? `<p class="govuk-body govuk-!-margin-bottom-0">${feature.properties.description}</p>`
202
+ : `<input class="govuk-input govuk-!-width-two-thirds" type="text" id="description_${index}" value="${feature.properties.description}" data-id="${feature.id}">`
203
+
204
+ // Change action link
205
+ const changeAction = () => `<li class="govuk-summary-list__actions-list-item">
206
+ <a class="govuk-link govuk-link--no-visited-state" href="#${mapId}" data-action="edit" data-id="${feature.id}"
207
+ data-type="${feature.geometry.type}">Update<span class="govuk-visually-hidden"> location</span></a>
208
+ </li>`
209
+
210
+ // Delete action link
211
+ const deleteAction = () => `<li class="govuk-summary-list__actions-list-item">
212
+ <a class="govuk-link govuk-link--no-visited-state" href="#" data-action="delete" data-id="${feature.id}"
213
+ data-type="${feature.geometry.type}">Delete<span class="govuk-visually-hidden"> location</span></a>
214
+ </li>`
215
+
216
+ // Focus action link
217
+ const focusAction = () => `<li class="govuk-summary-list__actions-list-item">
218
+ <a class="govuk-link govuk-link--no-visited-state" href="#${mapId}" data-action="focus" data-id="${feature.id}">Show<span class="govuk-visually-hidden"> location</span></a>
219
+ </li>`
220
+
221
+ const links = readonly ? focusAction() : `${changeAction()}${deleteAction()}`
222
+
223
+ const actions = `<ul class="govuk-summary-list__actions-list">${links}</ul>`
224
+
225
+ return `<div class="govuk-summary-list__row govuk-summary-list__row--no-border">
226
+ <dt class="govuk-summary-list__key">
227
+ <div class="govuk-form-group">
228
+ <label class="govuk-label govuk-label--s" ${readonly ? '' : `for="description_${index}"`}>Location ${index + 1} description</label>
229
+ ${description}
230
+ </div>
231
+ </dt>
232
+ <dd class="govuk-summary-list__actions">
233
+ ${actions}
234
+ </dd>
235
+ </div>
236
+ <div class="govuk-summary-list__row">
237
+ <details class="govuk-details govuk-!-margin-bottom-2">
238
+ <summary class="govuk-details__summary">
239
+ <span class="govuk-details__summary-text">Coordinates</span>
240
+ </summary>
241
+ <div class="govuk-details__text">
242
+ <dl class="govuk-summary-list">
243
+ <div class="govuk-summary-list__row">
244
+ <dt class="govuk-summary-list__key">Type</dt>
245
+ <dd class="govuk-summary-list__value">${typeDescriptions[feature.geometry.type]}</dd>
246
+ </div>
247
+ <div class="govuk-summary-list__row">
248
+ <dt class="govuk-summary-list__key">Center grid reference</dt>
249
+ <dd class="govuk-summary-list__value">${feature.properties.centroidGridReference}</dd>
250
+ </div>
251
+ <div class="govuk-summary-list__row">
252
+ <dt class="govuk-summary-list__key">First point grid reference</dt>
253
+ <dd class="govuk-summary-list__value">${feature.properties.coordinateGridReference}</dd>
254
+ </div>
255
+ <div class="govuk-summary-list__row">
256
+ <dt class="govuk-summary-list__key">Detailed coordinates</dt>
257
+ <dd class="govuk-summary-list__value">
258
+ <ol class="govuk-list govuk-list--number">${coordinates}</ol>
259
+ </dd>
260
+ </div>
261
+ </dl>
262
+ </div>
263
+ </details>
264
+ </div>`
265
+ }
266
+
267
+ /**
268
+ * Generate a random id
269
+ */
270
+ function generateID() {
271
+ return window.crypto.randomUUID()
272
+ }
273
+
274
+ /**
275
+ * Factory closure to track the active feature id
276
+ * @returns {ActiveFeatureManager}
277
+ */
278
+ function getActiveFeatureManager() {
279
+ /** @type {string | undefined} */
280
+ let activeFeature
281
+
282
+ /**
283
+ * Returns the active feature id
284
+ * @type {GetActiveFeature}
285
+ */
286
+ function getActiveFeature() {
287
+ return activeFeature
288
+ }
289
+
290
+ /**
291
+ * Sets the active feature id
292
+ * @type {SetActiveFeature}
293
+ */
294
+ function setActiveFeature(id) {
295
+ activeFeature = id
296
+ }
297
+
298
+ /**
299
+ * Resets the active feature id
300
+ * @type {ResetActiveFeature}
301
+ */
302
+ function resetActiveFeature() {
303
+ activeFeature = undefined
304
+ }
305
+
306
+ return {
307
+ getActiveFeature,
308
+ setActiveFeature,
309
+ resetActiveFeature
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Reduce coordinate precision to 7 dps
315
+ * @param {Feature} feature
316
+ */
317
+ function prepareGeometry(feature) {
318
+ const { geometry } = feature
319
+ const maxPrecision = 7
320
+
321
+ /**
322
+ * @param {Coordinates} coordinates
323
+ */
324
+ function formatPrecision(coordinates) {
325
+ coordinates[0] = +coordinates[0].toFixed(maxPrecision)
326
+ coordinates[1] = +coordinates[1].toFixed(maxPrecision)
327
+ }
328
+
329
+ if (geometry.type === 'Point') {
330
+ formatPrecision(geometry.coordinates)
331
+ } else if (geometry.type === 'LineString') {
332
+ geometry.coordinates.forEach(formatPrecision)
333
+ } else {
334
+ geometry.coordinates.flat().forEach(formatPrecision)
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Factory closure to return a features manager
340
+ * @param {GeoJSON} geojson
341
+ * @returns {FeaturesManager}
342
+ */
343
+ function getFeaturesManager(geojson) {
344
+ /**
345
+ * Get a feature from the geojson by id
346
+ * @type {GetFeatures}
347
+ */
348
+ function getFeatures() {
349
+ return geojson.features
350
+ }
351
+
352
+ /**
353
+ * Get a feature from the geojson by id
354
+ * @type {GetFeature}
355
+ */
356
+ function getFeature(id) {
357
+ return geojson.features.find((f) => f.id === id)
358
+ }
359
+
360
+ /**
361
+ * Add a feature to the geojson
362
+ * @type {AddFeature}
363
+ */
364
+ function addFeature(feature) {
365
+ feature.properties.coordinateGridReference = getCoordinateGridRef(feature)
366
+ feature.properties.centroidGridReference = getCentroidGridRef(feature)
367
+ prepareGeometry(feature)
368
+
369
+ geojson.features.push(feature)
370
+ }
371
+
372
+ /**
373
+ * Updates a feature in the geojson
374
+ * @type {UpdateFeature}
375
+ */
376
+ function updateFeature(id, geometry) {
377
+ const feature = getFeature(id)
378
+
379
+ // Ensure the feature exists in the geojson
380
+ if (feature) {
381
+ feature.properties.coordinateGridReference = getCoordinateGridRef(feature)
382
+ feature.properties.centroidGridReference = getCentroidGridRef(feature)
383
+ feature.geometry = geometry
384
+ prepareGeometry(feature)
385
+ }
386
+
387
+ return feature
388
+ }
389
+
390
+ /**
391
+ * Removes a feature from the geojson
392
+ * @type {RemoveFeature}
393
+ */
394
+ function removeFeature(id) {
395
+ const idx = geojson.features.findIndex((f) => f.id === id)
396
+
397
+ return idx > -1 ? geojson.features.splice(idx, 1) : undefined
398
+ }
399
+
400
+ return {
401
+ getFeatures,
402
+ getFeature,
403
+ addFeature,
404
+ updateFeature,
405
+ removeFeature
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Factory to render features into the list and hidden textarea
411
+ * @param {GeoJSON} geojson - the geojson of features
412
+ * @param {string} mapId - the ID of the map
413
+ * @param {HTMLDivElement} listEl - where to render the feature list
414
+ * @param {Function} renderValue - function that renders the features JSON into the hidden textarea
415
+ * @returns {RenderList}
416
+ */
417
+ function getListRenderer(geojson, mapId, listEl, renderValue) {
418
+ return function renderList() {
419
+ const html = createFeaturesHTML(geojson.features, mapId)
420
+
421
+ listEl.innerHTML = html
422
+
423
+ renderValue()
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Factory to render features JSON into the hidden textarea
429
+ * @param {GeoJSON} geojson - the features
430
+ * @param {HTMLTextAreaElement} geospatialInput - the geospatial textarea
431
+ * @returns {RenderValue}
432
+ */
433
+ function getValueRenderer(geojson, geospatialInput) {
434
+ return function renderValue() {
435
+ geospatialInput.value = JSON.stringify(geojson.features, null, 2)
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Factory closure to manage the UI
441
+ * @param {GeoJSON} geojson - the features
442
+ * @param {InteractiveMap} map - the map
443
+ * @param {string} mapId - the ID of the map
444
+ * @param {HTMLDivElement} listEl - where to render the feature list
445
+ * @param {HTMLTextAreaElement} geospatialInput - the geospatial textarea
446
+ */
447
+ function getUIManager(geojson, map, mapId, listEl, geospatialInput) {
448
+ /**
449
+ * Toggle the hidden state of the action buttons
450
+ * @type {ToggleActionButtons}
451
+ */
452
+ function toggleActionButtons(hidden) {
453
+ map.toggleButtonState('btnAddPoint', 'hidden', hidden)
454
+ map.toggleButtonState('btnAddPolygon', 'hidden', hidden)
455
+ map.toggleButtonState('btnAddLine', 'hidden', hidden)
456
+ }
457
+
458
+ /**
459
+ * Set focus to the last description input
460
+ * @type {FocusDescriptionInput}
461
+ */
462
+ function focusDescriptionInput() {
463
+ const inputs = listEl.querySelectorAll('input')
464
+ if (inputs.length) {
465
+ const lastInput = /** @type {HTMLInputElement} */ inputs.item(
466
+ inputs.length - 1
467
+ )
468
+ lastInput.focus()
469
+ lastInput.select()
470
+ }
471
+ }
472
+
473
+ const renderValue = getValueRenderer(geojson, geospatialInput)
474
+ const renderList = getListRenderer(geojson, mapId, listEl, renderValue)
475
+
476
+ /** @type {UIManager} */
477
+ return {
478
+ renderList,
479
+ renderValue,
480
+ listEl,
481
+ toggleActionButtons,
482
+ focusDescriptionInput
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Setup the UI event listeners
488
+ * @param {Context} context - the context
489
+ */
490
+ function addEventListeners(context) {
491
+ const { map } = context
492
+
493
+ map.on(EVENTS.mapReady, onMapReadyFactory(context))
494
+ }
495
+
496
+ /**
497
+ * Create the map and list containers and adds them to the DOM
498
+ * @param {HTMLTextAreaElement} geospatialInput - the textarea containing the geojson
499
+ * @param {number} index - the 0 based index
500
+ */
501
+ function createContainers(geospatialInput, index) {
502
+ const mapEl = document.createElement('div')
503
+ const mapId = `geospatialmap_${index}`
504
+
505
+ mapEl.setAttribute('id', mapId)
506
+ mapEl.setAttribute('class', 'map-container')
507
+
508
+ const listEl = document.createElement('div')
509
+ const listId = `${mapId}_list`
510
+ listEl.setAttribute('id', listId)
511
+
512
+ geospatialInput.after(mapEl)
513
+ mapEl.after(listEl)
514
+ geospatialInput.classList.add('js-hidden')
515
+
516
+ return { mapEl, listEl, mapId }
517
+ }
518
+
519
+ /**
520
+ * Callback factory function which fires when the map is ready
521
+ * @param {Context} context - the UI context
522
+ */
523
+ function onMapReadyFactory(context) {
524
+ const { map, activeFeatureManager, uiManager, interactPlugin, drawPlugin } =
525
+ context
526
+ const { toggleActionButtons } = uiManager
527
+ const { resetActiveFeature } = activeFeatureManager
528
+
529
+ /**
530
+ * Callback function which fires when the map is ready
531
+ * @param {object} e - the event
532
+ * @param {MapLibreMap} e.map - the map provider instance
533
+ */
534
+ return function onMapReady(e) {
535
+ // Add info panel
536
+ map.addPanel('info', helpPanelConfig)
537
+
538
+ map.addButton('btnAddPoint', {
539
+ variant: 'tertiary',
540
+ label: 'Add point',
541
+ iconSvgContent: POINT_SVG,
542
+ onClick: () => {
543
+ resetActiveFeature()
544
+ toggleActionButtons(true)
545
+ interactPlugin.enable()
546
+ },
547
+ mobile: { slot: 'actions' },
548
+ tablet: { slot: 'actions' },
549
+ desktop: { slot: 'actions' }
550
+ })
551
+
552
+ map.addButton('btnAddPolygon', {
553
+ variant: 'tertiary',
554
+ label: 'Add shape',
555
+ iconSvgContent: POLYGON_SVG,
556
+ onClick: () => {
557
+ resetActiveFeature()
558
+ toggleActionButtons(true)
559
+ drawPlugin.newPolygon(generateID(), polygonFeatureProperties)
560
+ },
561
+ mobile: { slot: 'actions' },
562
+ tablet: { slot: 'actions' },
563
+ desktop: { slot: 'actions' }
564
+ })
565
+
566
+ map.addButton('btnAddLine', {
567
+ variant: 'tertiary',
568
+ label: 'Add line',
569
+ iconSvgContent: LINE_SVG,
570
+ onClick: () => {
571
+ resetActiveFeature()
572
+ toggleActionButtons(true)
573
+ drawPlugin.newLine(generateID(), lineFeatureProperties)
574
+ },
575
+ mobile: { slot: 'actions' },
576
+ tablet: { slot: 'actions' },
577
+ desktop: { slot: 'actions' }
578
+ })
579
+
580
+ // Set the map provider on the context
581
+ context.mapProvider = e.map
582
+
583
+ map.on(EVENTS.drawReady, onDrawReadyFactory(context))
584
+ map.on(EVENTS.drawCreated, onDrawCreatedFactory(context))
585
+ map.on(EVENTS.drawEdited, onDrawEditedFactory(context))
586
+ map.on(EVENTS.drawCancelled, onDrawCancelledFactory(context))
587
+ map.on(EVENTS.interactMarkerChange, onInteractMarkerChangedFactory(context))
588
+
589
+ const { listEl } = uiManager
590
+ listEl.addEventListener('click', onListElClickFactory(context), false)
591
+ listEl.addEventListener('change', onListElChangeFactory(context), false)
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Callback factory function which fires when the map draw plugin is ready
597
+ * @param {Context} context - the UI context
598
+ */
599
+ function onDrawReadyFactory(context) {
600
+ const { featuresManager, uiManager, drawPlugin, map } = context
601
+ const { renderList } = uiManager
602
+ const { getFeatures } = featuresManager
603
+
604
+ /**
605
+ * Callback when the draw plugin is ready
606
+ */
607
+ return function onDrawReady() {
608
+ getFeatures().forEach((feature) =>
609
+ addFeatureToMap(feature, drawPlugin, map)
610
+ )
611
+
612
+ // Update the features
613
+ renderList()
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Callback factory function which fires when the map draw plugin creates a new feature
619
+ * @param {Context} context - the UI context
620
+ */
621
+ function onDrawCreatedFactory(context) {
622
+ const { featuresManager, uiManager } = context
623
+ const { addFeature } = featuresManager
624
+ const { renderList, toggleActionButtons, focusDescriptionInput } = uiManager
625
+
626
+ /**
627
+ * Callback when a draw feature has been created
628
+ * @param {Feature} e
629
+ */
630
+ return function onDrawCreated(e) {
631
+ // New feature
632
+ addFeature({
633
+ ...e,
634
+ properties: {
635
+ description: ''
636
+ }
637
+ })
638
+
639
+ // Update the features
640
+ renderList()
641
+ toggleActionButtons(false)
642
+ focusDescriptionInput()
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Callback factory function which fires when the map draw plugin edits a feature
648
+ * @param {Context} context - the UI context
649
+ */
650
+ function onDrawEditedFactory(context) {
651
+ const { featuresManager, activeFeatureManager, uiManager } = context
652
+ const { updateFeature } = featuresManager
653
+ const { getActiveFeature, resetActiveFeature } = activeFeatureManager
654
+ const { renderList, toggleActionButtons } = uiManager
655
+
656
+ /**
657
+ * Callback when a draw feature has been edited
658
+ * @param {{ id: string, geometry: Geometry }} e
659
+ */
660
+ return function onDrawEdited(e) {
661
+ const changedFeature = e
662
+ const featureId = changedFeature.id
663
+
664
+ if (getActiveFeature() === featureId) {
665
+ updateFeature(featureId, changedFeature.geometry)
666
+
667
+ // Update the features
668
+ renderList()
669
+ }
670
+
671
+ resetActiveFeature()
672
+ toggleActionButtons(false)
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Callback factory function which fires when the map draw plugin cancels the editing of a feature
678
+ * @param {Context} context - the UI context
679
+ */
680
+ function onDrawCancelledFactory(context) {
681
+ const { uiManager, activeFeatureManager } = context
682
+ const { toggleActionButtons } = uiManager
683
+ const { resetActiveFeature } = activeFeatureManager
684
+
685
+ /**
686
+ * Callback when a draw feature has been cancelled
687
+ */
688
+ return function onDrawCancelled() {
689
+ toggleActionButtons(false)
690
+ resetActiveFeature()
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Callback factory function that fires when an interact marker has been changed
696
+ * @param {Context} context - the UI context
697
+ */
698
+ function onInteractMarkerChangedFactory(context) {
699
+ const {
700
+ featuresManager,
701
+ activeFeatureManager,
702
+ map,
703
+ interactPlugin,
704
+ uiManager
705
+ } = context
706
+ const { addFeature, updateFeature } = featuresManager
707
+ const { getActiveFeature, resetActiveFeature } = activeFeatureManager
708
+ const { renderList, focusDescriptionInput, toggleActionButtons } = uiManager
709
+
710
+ /**
711
+ * Callback when an interact marker has been changed
712
+ * @param {{ coords: Coordinates }} e
713
+ */
714
+ return function onInteractMarkerChange(e) {
715
+ const activeFeatureId = getActiveFeature()
716
+
717
+ if (activeFeatureId) {
718
+ // Editing an existing point
719
+ const feature = updateFeature(activeFeatureId, {
720
+ type: 'Point',
721
+ coordinates: e.coords
722
+ })
723
+
724
+ map.addMarker(activeFeatureId, e.coords)
725
+
726
+ if (feature) {
727
+ // Update the features
728
+ renderList()
729
+ }
730
+
731
+ resetActiveFeature()
732
+ } else {
733
+ // Adding a new point
734
+ const id = generateID()
735
+ addFeature({
736
+ type: 'Feature',
737
+ properties: {
738
+ description: ''
739
+ },
740
+ geometry: {
741
+ type: 'Point',
742
+ coordinates: e.coords
743
+ },
744
+ id
745
+ })
746
+
747
+ map.addMarker(id, e.coords)
748
+
749
+ // Update the features
750
+ renderList()
751
+
752
+ focusDescriptionInput()
753
+ }
754
+ map.removeMarker('location')
755
+
756
+ interactPlugin.disable()
757
+ toggleActionButtons(false)
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Callback factory function that fires a 'click' event is fired on the list container
763
+ * @param {Context} context - the UI context
764
+ */
765
+ function onListElClickFactory(context) {
766
+ const {
767
+ map,
768
+ mapProvider,
769
+ featuresManager,
770
+ activeFeatureManager,
771
+ interactPlugin,
772
+ uiManager,
773
+ drawPlugin
774
+ } = context
775
+ const { getFeature, removeFeature } = featuresManager
776
+ const { getActiveFeature, setActiveFeature } = activeFeatureManager
777
+ const { renderList, toggleActionButtons } = uiManager
778
+
779
+ /**
780
+ * Delete a feature
781
+ * @param {string} id - the feature id
782
+ * @param {string} type - the feature type
783
+ */
784
+ function deleteFeature(id, type) {
785
+ if (type === 'Point') {
786
+ map.removeMarker(id)
787
+ removeFeature(id)
788
+ } else {
789
+ drawPlugin.deleteFeature(id)
790
+ removeFeature(id)
791
+ }
792
+
793
+ renderList()
794
+ }
795
+
796
+ /**
797
+ * Start editing feature
798
+ * @param {string} id - the feature id
799
+ * @param {string} type - the feature type
800
+ */
801
+ function editFeature(id, type) {
802
+ setActiveFeature(id)
803
+
804
+ // "Change" feature link was clicked
805
+ if (type === 'Point') {
806
+ interactPlugin.selectFeature({ featureId: id })
807
+ interactPlugin.enable()
808
+ } else {
809
+ drawPlugin.editFeature(id)
810
+ }
811
+
812
+ const feature = getFeature(id)
813
+ if (feature && mapProvider) {
814
+ focusFeature(feature, mapProvider)
815
+ }
816
+
817
+ toggleActionButtons(true)
818
+ }
819
+
820
+ /**
821
+ * List container delegated 'click' events handler
822
+ * @param {MouseEvent} e
823
+ */
824
+ return function (e) {
825
+ const target = e.target
826
+
827
+ if (!(target instanceof HTMLElement)) {
828
+ return
829
+ }
830
+
831
+ if (getActiveFeature()) {
832
+ e.preventDefault()
833
+ e.stopPropagation()
834
+ return
835
+ }
836
+
837
+ if (
838
+ target.tagName === 'A' &&
839
+ target.dataset.action &&
840
+ target.dataset.id &&
841
+ target.dataset.type
842
+ ) {
843
+ const { action, id, type } = target.dataset
844
+
845
+ if (action === 'edit') {
846
+ // "Update" feature link was clicked
847
+ editFeature(id, type)
848
+ } else {
849
+ e.preventDefault()
850
+ e.stopPropagation()
851
+
852
+ if (action === 'delete') {
853
+ // "Remove" feature link was clicked
854
+ deleteFeature(id, type)
855
+ }
856
+ }
857
+ }
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Callback factory function that fires a 'change' event is fired on the list container
863
+ * @param {Context} context - the UI context
864
+ */
865
+ function onListElChangeFactory(context) {
866
+ const { featuresManager, uiManager } = context
867
+ const { getFeature } = featuresManager
868
+ const { renderValue } = uiManager
869
+
870
+ /**
871
+ * List container delegated 'change' events handler
872
+ * Used to update the description of features
873
+ * @param {Event} e
874
+ */
875
+ return function (e) {
876
+ e.preventDefault()
877
+ e.stopPropagation()
878
+
879
+ const target = e.target
880
+ if (!(target instanceof HTMLInputElement) || !target.dataset.id) {
881
+ return
882
+ }
883
+
884
+ const { id } = target.dataset
885
+ const feature = getFeature(id)
886
+
887
+ if (feature) {
888
+ feature.properties.description = target.value.trim()
889
+ renderValue()
890
+ }
891
+ }
892
+ }
893
+
894
+ /**
895
+ * @import { MapsEnvironmentConfig, InteractiveMap } from '~/src/client/javascripts/map.js'
896
+ */
897
+
898
+ /**
899
+ * @import { Geometry, FeatureCollection, Feature, Coordinates } from '~/src/server/plugins/engine/types.js'
900
+ */
901
+
902
+ /**
903
+ * @typedef {object} GeoJSON
904
+ * @property {'FeatureCollection'} type - the GeoJSON type string
905
+ * @property {FeatureCollection} features - the features
906
+ */
907
+
908
+ /**
909
+ * Gets all the features
910
+ * @callback GetFeatures
911
+ * @returns {FeatureCollection}
912
+ */
913
+
914
+ /**
915
+ * Gets a feature from the geojson by id
916
+ * @callback GetFeature
917
+ * @param {string} id - the feature id
918
+ * @returns {Feature | undefined}
919
+ */
920
+
921
+ /**
922
+ * Add a feature to the geojson
923
+ * @callback AddFeature
924
+ * @param {Feature} feature - the feature to add
925
+ */
926
+
927
+ /**
928
+ * Update a feature in the geojson
929
+ * @callback UpdateFeature
930
+ * @param {string} id - the feature id
931
+ * @param {Geometry} geometry - the feature geometry
932
+ * @returns {Feature | undefined}
933
+ */
934
+
935
+ /**
936
+ * Removes a feature from the geojson
937
+ * @callback RemoveFeature
938
+ * @param {string} id - the feature id
939
+ */
940
+
941
+ /**
942
+ * Gets the active feature id
943
+ * @callback GetActiveFeature
944
+ * @returns {string | undefined}
945
+ */
946
+
947
+ /**
948
+ * Set the active feature id
949
+ * @callback SetActiveFeature
950
+ * @param {string} id - the feature id
951
+ * @returns {void}
952
+ */
953
+
954
+ /**
955
+ * Resets the active feature id
956
+ * @callback ResetActiveFeature
957
+ * @returns {void}
958
+ */
959
+
960
+ /**
961
+ * Renders the features into the list
962
+ * @callback RenderList
963
+ * @returns {void}
964
+ */
965
+
966
+ /**
967
+ * Renders the features JSON into the hidden textarea
968
+ * @callback RenderValue
969
+ * @returns {void}
970
+ */
971
+
972
+ /**
973
+ * Toggles the action button hidden state
974
+ * @callback ToggleActionButtons
975
+ * @param {boolean} hidden - whether to hide the action buttons
976
+ * @returns {void}
977
+ */
978
+
979
+ /**
980
+ * Set focus to the last description input
981
+ * @callback FocusDescriptionInput
982
+ * @returns {void}
983
+ */
984
+
985
+ /**
986
+ * @typedef {object} FeaturesManager
987
+ * @property {GetFeatures} getFeatures - function that gets all the features
988
+ * @property {GetFeature} getFeature - function that gets a feature from the geojson
989
+ * @property {AddFeature} addFeature - function that adds feature to the geojson
990
+ * @property {UpdateFeature} updateFeature - function that updates a feature in the geojson
991
+ * @property {RemoveFeature} removeFeature - function that removes a feature from the geojson
992
+ */
993
+
994
+ /**
995
+ * @typedef {object} ActiveFeatureManager
996
+ * @property {GetActiveFeature} getActiveFeature - function that returns the current active feature id
997
+ * @property {SetActiveFeature} setActiveFeature - function that sets the current active feature id
998
+ * @property {ResetActiveFeature} resetActiveFeature - function that resets the current active feature id
999
+ */
1000
+
1001
+ /**
1002
+ * @typedef {object} UIManager
1003
+ * @property {RenderValue} renderValue - function that renders the features JSON into the hidden textarea
1004
+ * @property {RenderList} renderList - function that renders the features into the list
1005
+ * @property {HTMLDivElement} listEl - the summary list of features
1006
+ * @property {ToggleActionButtons} toggleActionButtons - function that toggles the action buttons
1007
+ * @property {FocusDescriptionInput} focusDescriptionInput - function that sets focus to a description input element
1008
+ */
1009
+
1010
+ /**
1011
+ * @typedef {object} Context
1012
+ * @property {InteractiveMap} map - the interactive map
1013
+ * @property {MapLibreMap} [mapProvider] - the interactive map provider
1014
+ * @property {FeaturesManager} featuresManager - the features manager
1015
+ * @property {ActiveFeatureManager} activeFeatureManager - the active feature manager
1016
+ * @property {UIManager} uiManager - the UI manager
1017
+ * @property {any} interactPlugin - the map interact plugin
1018
+ * @property {any} drawPlugin - the map draw plugin
1019
+ */
1020
+
1021
+ /**
1022
+ * @import { MapLibreMap } from '~/src/client/javascripts/map.js'
1023
+ */