diff --git a/jest.config.cjs b/jest.config.cjs index d4bbf3a61..6609106df 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -51,7 +51,8 @@ module.exports = { '@defra/forms-model/.*', 'nanoid', // Supports ESM only 'slug', // Supports ESM only - '@defra/hapi-tracing' // Supports ESM only| + '@defra/hapi-tracing', // Supports ESM only + 'geodesy' // Supports ESM only| ].join('|')}/)` ], testTimeout: 10000, diff --git a/package-lock.json b/package-lock.json index b1ba92754..d72173540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.3", "expr-eval-fork": "^3.0.0", + "geodesy": "^2.4.0", "govuk-frontend": "^5.13.0", "hapi-pino": "^13.0.0", "hapi-pulse": "^3.0.1", @@ -11603,6 +11604,15 @@ "node": ">=6.9.0" } }, + "node_modules/geodesy": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/geodesy/-/geodesy-2.4.0.tgz", + "integrity": "sha512-tHjQ1sXq8UAIEg1V0Pa6mznUxGU0R+3H5PIF6NULr0yPCAVLKqJro93Bbr19jSE18BMfyjN4osWDI4sm92m0kw==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/geojson-equality-ts": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/geojson-equality-ts/-/geojson-equality-ts-1.0.2.tgz", diff --git a/package.json b/package.json index 941102ad1..0283774d4 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.3", "expr-eval-fork": "^3.0.0", + "geodesy": "^2.4.0", "govuk-frontend": "^5.13.0", "hapi-pino": "^13.0.0", "hapi-pulse": "^3.0.1", diff --git a/src/client/javascripts/location-map.js b/src/client/javascripts/location-map.js index 50565d5bf..9c2c9bbc6 100644 --- a/src/client/javascripts/location-map.js +++ b/src/client/javascripts/location-map.js @@ -1,3 +1,33 @@ +// @ts-expect-error - no types +import OsGridRef, { LatLon } from 'geodesy/osgridref.js' + +/** + * Converts lat long to easting and northing + * @param {object} param + * @param {number} param.lat + * @param {number} param.long + * @returns {{ easting: number, northing: number }} + */ +function latLongToEastingNorthing({ lat, long }) { + const point = new LatLon(lat, long) + + return point.toOsGrid() +} + +/** + * Converts easting and northing to lat long + * @param {object} param + * @param {number} param.easting + * @param {number} param.northing + * @returns {{ lat: number, long: number }} + */ +function eastingNorthingToLatLong({ easting, northing }) { + const point = new OsGridRef(easting, northing) + const latLong = point.toLatLon() + + return { lat: latLong.latitude, long: latLong.longitude } +} + // Center of UK const DEFAULT_LAT = 53.825564 const DEFAULT_LONG = -2.421975 @@ -115,7 +145,7 @@ function processLocation(config, location, index) { const locationType = location.dataset.locationtype // Check for support - const supportedLocations = ['latlongfield'] + const supportedLocations = ['latlongfield', 'eastingnorthingfield'] if (!locationType || !supportedLocations.includes(locationType)) { return } @@ -144,6 +174,9 @@ function processLocation(config, location, index) { case 'latlongfield': bindLatLongField(location, map, e.map) break + case 'eastingnorthingfield': + bindEastingNorthingField(location, map, e.map) + break default: throw new Error('Not implemented') } @@ -267,6 +300,8 @@ function getInitMapConfig(locationField) { switch (locationType) { case 'latlongfield': return getInitLatLongMapConfig(locationField) + case 'eastingnorthingfield': + return getInitEastingNorthingMapConfig(locationField) default: throw new Error('Not implemented') } @@ -301,6 +336,35 @@ function validateLatLong(strLat, strLong) { return { valid: true, value: { lat, long } } } +/** + * Validates easting and northing is numeric and within UK bounds + * @param {string} strEasting - the easting string + * @param {string} strNorthing - the northing string + * @returns {{ valid: false } | { valid: true, value: { easting: number, northing: number } }} + */ +function validateEastingNorthing(strEasting, strNorthing) { + const easting = strEasting.trim() && Number(strEasting.trim()) + const northing = strNorthing.trim() && Number(strNorthing.trim()) + + if (!easting || !northing) { + return { valid: false } + } + + const eastingMin = 0 + const eastingMax = 700000 + const northingMin = 0 + const northingMax = 1300000 + + const latInBounds = easting >= eastingMin && easting <= eastingMax + const longInBounds = northing >= northingMin && northing <= northingMax + + if (!latInBounds || !longInBounds) { + return { valid: false } + } + + return { valid: true, value: { easting, northing } } +} + /** * Gets initial map config for a latlong location field * @param {HTMLDivElement} locationField - the latlong location field element @@ -318,6 +382,23 @@ function getLatLongInputs(locationField) { return { latInput, longInput } } +/** + * Gets initial map config for a easting/northing location field + * @param {HTMLDivElement} locationField - the eastingnorthing location field element + */ +function getEastingNorthingInputs(locationField) { + const inputs = locationField.querySelectorAll('input.govuk-input') + + if (inputs.length !== 2) { + throw new Error('Expected 2 inputs for easting and northing') + } + + const eastingInput = /** @type {HTMLInputElement} */ (inputs[0]) + const northingInput = /** @type {HTMLInputElement} */ (inputs[1]) + + return { eastingInput, northingInput } +} + /** * Gets initial map config for a latlong location field * @param {HTMLDivElement} locationField - the latlong location field element @@ -331,13 +412,50 @@ function getInitLatLongMapConfig(locationField) { return undefined } + /** @type {MapCenter} */ + const center = [result.value.long, result.value.lat] + return { zoom: '16', - center: [result.value.long, result.value.lat], + center, markers: [ { id: 'location', - coords: [result.value.long, result.value.lat] + coords: center + } + ] + } +} + +/** + * Gets initial map config for a easting/northing location field + * @param {HTMLDivElement} locationField - the eastingnorthing location field element + * @returns {DefraMapInitConfig | undefined} + */ +function getInitEastingNorthingMapConfig(locationField) { + const { eastingInput, northingInput } = + getEastingNorthingInputs(locationField) + const result = validateEastingNorthing( + eastingInput.value, + northingInput.value + ) + + if (!result.valid) { + return undefined + } + + const latlong = eastingNorthingToLatLong(result.value) + + /** @type {MapCenter} */ + const center = [latlong.long, latlong.lat] + + return { + zoom: '16', + center, + markers: [ + { + id: 'location', + coords: center } ] } @@ -393,6 +511,67 @@ function bindLatLongField(locationField, map, mapProvider) { longInput.addEventListener('change', onUpdateInputs, false) } +/** + * Bind an eastingnorthing field to the map + * @param {HTMLDivElement} locationField - the eastingnorthing location field + * @param {DefraMap} map - the map component instance (of DefraMap) + * @param {MapLibreMap} mapProvider - the map provider instance (of MapLibreMap) + */ +function bindEastingNorthingField(locationField, map, mapProvider) { + const { eastingInput, northingInput } = + getEastingNorthingInputs(locationField) + + map.on( + 'interact:markerchange', + /** + * Callback function which fires when the map marker changes + * @param {object} e - the event + * @param {[number, number]} e.coords - the map marker coordinates + */ + function onInteractMarkerChange(e) { + const maxPrecision = 0 + const point = latLongToEastingNorthing({ + lat: e.coords[1], + long: e.coords[0] + }) + + eastingInput.value = point.easting.toFixed(maxPrecision) + northingInput.value = point.northing.toFixed(maxPrecision) + } + ) + + /** + * Easting & northing input change event listener + * Update the map view location when the inputs are changed + */ + function onUpdateInputs() { + const result = validateEastingNorthing( + eastingInput.value, + northingInput.value + ) + + if (result.valid) { + const latlong = eastingNorthingToLatLong(result.value) + + /** @type {MapCenter} */ + const center = [latlong.long, latlong.lat] + + // Move the 'location' marker to the new point + map.addMarker('location', center) + + // Pan & zoom the map to the new valid location + mapProvider.flyTo({ + center, + zoom: 14, + essential: true + }) + } + } + + eastingInput.addEventListener('change', onUpdateInputs, false) + northingInput.addEventListener('change', onUpdateInputs, false) +} + /** * @typedef {object} DefraMap - an instance of a DefraMap * @property {Function} on - register callback listeners to map events diff --git a/test/client/javascripts/location-map.test.js b/test/client/javascripts/location-map.test.js index 4b7b02ac4..00bb8e0b3 100644 --- a/test/client/javascripts/location-map.test.js +++ b/test/client/javascripts/location-map.test.js @@ -64,7 +64,7 @@ describe('Location Maps Client JS', () => {
- +
@@ -75,7 +75,7 @@ describe('Location Maps Client JS', () => { Longitude
- +
@@ -116,26 +116,235 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) + const inputs = document.body.querySelectorAll('input.govuk-input') + expect(inputs).toHaveLength(2) + + const latInput = /** @type {HTMLInputElement} */ (inputs[0]) + const longInput = /** @type {HTMLInputElement} */ (inputs[1]) + + latInput.value = '53.825564' + latInput.dispatchEvent(new window.Event('change')) + + longInput.value = '-2.421975' + longInput.dispatchEvent(new window.Event('change')) + + // Expect it to update once, only when both fields are valid + expect(addMarkerMock).toHaveBeenCalledTimes(1) + expect(flyToMock).toHaveBeenCalledTimes(1) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const onInteractMarkerChange = onMock.mock.calls[1][1] expect(typeof onInteractMarkerChange).toBe('function') - onInteractMarkerChange({ coords: [0, 0] }) + onInteractMarkerChange({ coords: [-2.1478238, 54.155676] }) + }) + test('initMaps with initial values', () => { const inputs = document.body.querySelectorAll('input.govuk-input') expect(inputs).toHaveLength(2) const latInput = /** @type {HTMLInputElement} */ (inputs[0]) const longInput = /** @type {HTMLInputElement} */ (inputs[1]) + // Set some initial values prior to initMaps + latInput.value = '53.743697' + longInput.value = '-1.522781' + + expect(() => initMaps()).not.toThrow() + expect(onMock).toHaveBeenLastCalledWith( + 'map:ready', + expect.any(Function) + ) + + const onMapReady = onMock.mock.calls[0][1] + expect(typeof onMapReady).toBe('function') + + // Manually invoke onMapReady callback + const flyToMock = jest.fn() + onMapReady({ + map: { + flyTo: flyToMock + } + }) + + expect(addPanelMock).toHaveBeenCalledWith('info', expect.any(Object)) + + expect(onMock).toHaveBeenLastCalledWith( + 'interact:markerchange', + expect.any(Function) + ) + latInput.value = '53.825564' latInput.dispatchEvent(new window.Event('change')) longInput.value = '-2.421975' longInput.dispatchEvent(new window.Event('change')) + // Expect it to update twice as both fields are already valid + expect(addMarkerMock).toHaveBeenCalledTimes(2) + expect(flyToMock).toHaveBeenCalledTimes(2) + }) + + test('initMaps only applies when there are location components on the page', () => { + const locations = document.querySelectorAll('.app-location-field') + + // Remove any locations for the test + locations.forEach((location) => { + location.remove() + }) + + expect(() => initMaps()).not.toThrow() + expect(onMock).not.toHaveBeenCalled() + }) + + test('initMaps only applies when there are supported location components on the page', () => { + const locations = document.querySelectorAll('.app-location-field') + + // Reset the location type of each component + locations.forEach((location) => { + location.setAttribute('data-locationtype', 'unknowntype') + }) + + expect(() => initMaps()).not.toThrow() + expect(onMock).not.toHaveBeenCalled() + }) + }) + }) + + describe('Easting northing component', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+
+
+ + What is your easting and northing + +
+ For example. Easting: 248741, Northing: 63688 +
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+ ` + }) + + describe('Map initialisation', () => { + test('initMaps easting northing initializes without errors when DOM elements are present', () => { + expect(() => initMaps()).not.toThrow() + expect(onMock).toHaveBeenLastCalledWith( + 'map:ready', + expect.any(Function) + ) + + const onMapReady = onMock.mock.calls[0][1] + expect(typeof onMapReady).toBe('function') + + // Manually invoke onMapReady callback + const flyToMock = jest.fn() + onMapReady({ + map: { + flyTo: flyToMock + } + }) + + expect(addPanelMock).toHaveBeenCalledWith('info', expect.any(Object)) + + expect(onMock).toHaveBeenLastCalledWith( + 'interact:markerchange', + expect.any(Function) + ) + + const inputs = document.body.querySelectorAll('input.govuk-input') + expect(inputs).toHaveLength(2) + + const eastingInput = /** @type {HTMLInputElement} */ (inputs[0]) + const northingInput = /** @type {HTMLInputElement} */ (inputs[1]) + + eastingInput.value = '380779' + eastingInput.dispatchEvent(new window.Event('change')) + + northingInput.value = '462222' + northingInput.dispatchEvent(new window.Event('change')) + // Expect it to update once, only when both fields are valid expect(addMarkerMock).toHaveBeenCalledTimes(1) expect(flyToMock).toHaveBeenCalledTimes(1) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const onInteractMarkerChange = onMock.mock.calls[1][1] + expect(typeof onInteractMarkerChange).toBe('function') + onInteractMarkerChange({ + coords: [-2.147823, 54.155676] + }) + }) + + test('initMaps with initial values', () => { + const inputs = document.body.querySelectorAll('input.govuk-input') + expect(inputs).toHaveLength(2) + + const eastingInput = /** @type {HTMLInputElement} */ (inputs[0]) + const northingInput = /** @type {HTMLInputElement} */ (inputs[1]) + + // Set some initial values prior to initMaps + eastingInput.value = '431571' + northingInput.value = '427585' + + expect(() => initMaps()).not.toThrow() + expect(onMock).toHaveBeenLastCalledWith( + 'map:ready', + expect.any(Function) + ) + + const onMapReady = onMock.mock.calls[0][1] + expect(typeof onMapReady).toBe('function') + + // Manually invoke onMapReady callback + const flyToMock = jest.fn() + onMapReady({ + map: { + flyTo: flyToMock + } + }) + + expect(addPanelMock).toHaveBeenCalledWith('info', expect.any(Object)) + + expect(onMock).toHaveBeenLastCalledWith( + 'interact:markerchange', + expect.any(Function) + ) + + eastingInput.value = '380779' + eastingInput.dispatchEvent(new window.Event('change')) + + northingInput.value = '462222' + northingInput.dispatchEvent(new window.Event('change')) + + // Expect it to update twice as both fields are already valid + expect(addMarkerMock).toHaveBeenCalledTimes(2) + expect(flyToMock).toHaveBeenCalledTimes(2) }) test('initMaps only applies when there are location components on the page', () => {