From 9a505a8048669ec69d03d7449f579d8ed1545c39 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 14 Jan 2026 11:37:11 +0000 Subject: [PATCH 1/5] Add eastingnorthingfield to the list of supported location fields --- src/client/javascripts/location-map.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/javascripts/location-map.js b/src/client/javascripts/location-map.js index 50565d5bf..b930ae664 100644 --- a/src/client/javascripts/location-map.js +++ b/src/client/javascripts/location-map.js @@ -115,7 +115,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 } From 722f1a74615b3e19dc15385ecc034bce42cbdbb8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 15 Jan 2026 14:32:56 +0000 Subject: [PATCH 2/5] Add easting northing capability to location maps --- package-lock.json | 10 ++ package.json | 1 + src/client/javascripts/location-map.js | 183 ++++++++++++++++++++++++- src/client/javascripts/osgrid.js | 164 ++++++++++++++++++++++ 4 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 src/client/javascripts/osgrid.js 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 b930ae664..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 @@ -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/src/client/javascripts/osgrid.js b/src/client/javascripts/osgrid.js new file mode 100644 index 000000000..41d432003 --- /dev/null +++ b/src/client/javascripts/osgrid.js @@ -0,0 +1,164 @@ +/** + * + * @param {*} gridref + * @returns + */ +export function osGridToLatLong(gridref) { + const E = gridref.easting + const N = gridref.northing + + const a = 6377563.396 + const b = 6356256.91 // Airy 1830 major & minor semi-axes + const F0 = 0.9996012717 // NatGrid scale factor on central meridian + const lat0 = (49 * Math.PI) / 180 + const lon0 = (-2 * Math.PI) / 180 // NatGrid true origin + const N0 = -100000 + const E0 = 400000 // northing & easting of true origin, metres + const e2 = 1 - (b * b) / (a * a) // eccentricity squared + const n = (a - b) / (a + b) + const n2 = n * n + const n3 = n * n * n + + let lat = lat0 + let M = 0 + do { + lat = (N - N0 - M) / (a * F0) + lat + + const Ma = (1 + n + (5 / 4) * n2 + (5 / 4) * n3) * (lat - lat0) + const Mb = + (3 * n + 3 * n * n + (21 / 8) * n3) * + Math.sin(lat - lat0) * + Math.cos(lat + lat0) + const Mc = + ((15 / 8) * n2 + (15 / 8) * n3) * + Math.sin(2 * (lat - lat0)) * + Math.cos(2 * (lat + lat0)) + const Md = + (35 / 24) * n3 * Math.sin(3 * (lat - lat0)) * Math.cos(3 * (lat + lat0)) + M = b * F0 * (Ma - Mb + Mc - Md) // meridional arc + } while (N - N0 - M >= 0.00001) // ie until < 0.01mm + + const cosLat = Math.cos(lat) + const sinLat = Math.sin(lat) + const nu = (a * F0) / Math.sqrt(1 - e2 * sinLat * sinLat) // transverse radius of curvature + const rho = (a * F0 * (1 - e2)) / Math.pow(1 - e2 * sinLat * sinLat, 1.5) // meridional radius of curvature + const eta2 = nu / rho - 1 + + const tanLat = Math.tan(lat) + const tan2lat = tanLat * tanLat + const tan4lat = tan2lat * tan2lat + const tan6lat = tan4lat * tan2lat + const secLat = 1 / cosLat + const nu3 = nu * nu * nu + const nu5 = nu3 * nu * nu + const nu7 = nu5 * nu * nu + const VII = tanLat / (2 * rho * nu) + const VIII = + (tanLat / (24 * rho * nu3)) * (5 + 3 * tan2lat + eta2 - 9 * tan2lat * eta2) + const IX = (tanLat / (720 * rho * nu5)) * (61 + 90 * tan2lat + 45 * tan4lat) + const X = secLat / nu + const XI = (secLat / (6 * nu3)) * (nu / rho + 2 * tan2lat) + const XII = (secLat / (120 * nu5)) * (5 + 28 * tan2lat + 24 * tan4lat) + const XIIA = + (secLat / (5040 * nu7)) * + (61 + 662 * tan2lat + 1320 * tan4lat + 720 * tan6lat) + + const dE = E - E0 + const dE2 = dE * dE + const dE3 = dE2 * dE + const dE4 = dE2 * dE2 + const dE5 = dE3 * dE2 + const dE6 = dE4 * dE2 + const dE7 = dE5 * dE2 + lat = lat - VII * dE2 + VIII * dE4 - IX * dE6 + const lon = lon0 + X * dE - XI * dE3 + XII * dE5 - XIIA * dE7 + + return { lat: toDeg(lat), long: toDeg(lon) } +} + +/** + * + * @param {*} point + * @returns + */ +export function latLongToOsGrid(point) { + const lat = toRad(point.lat) + const lon = toRad(point.long) + + const a = 6377563.396 + const b = 6356256.91 // Airy 1830 major & minor semi-axes + const F0 = 0.9996012717 // NatGrid scale factor on central meridian + const lat0 = toRad(49) + const lon0 = toRad(-2) // NatGrid true origin is 49ºN,2ºW + const N0 = -100000 + const E0 = 400000 // northing & easting of true origin, metres + const e2 = 1 - (b * b) / (a * a) // eccentricity squared + const n = (a - b) / (a + b) + const n2 = n * n + const n3 = n * n * n + + const cosLat = Math.cos(lat) + const sinLat = Math.sin(lat) + const nu = (a * F0) / Math.sqrt(1 - e2 * sinLat * sinLat) // transverse radius of curvature + const rho = (a * F0 * (1 - e2)) / Math.pow(1 - e2 * sinLat * sinLat, 1.5) // meridional radius of curvature + const eta2 = nu / rho - 1 + + const Ma = (1 + n + (5 / 4) * n2 + (5 / 4) * n3) * (lat - lat0) + const Mb = + (3 * n + 3 * n * n + (21 / 8) * n3) * + Math.sin(lat - lat0) * + Math.cos(lat + lat0) + const Mc = + ((15 / 8) * n2 + (15 / 8) * n3) * + Math.sin(2 * (lat - lat0)) * + Math.cos(2 * (lat + lat0)) + const Md = + (35 / 24) * n3 * Math.sin(3 * (lat - lat0)) * Math.cos(3 * (lat + lat0)) + const M = b * F0 * (Ma - Mb + Mc - Md) // meridional arc + + const cos3lat = cosLat * cosLat * cosLat + const cos5lat = cos3lat * cosLat * cosLat + const tan2lat = Math.tan(lat) * Math.tan(lat) + const tan4lat = tan2lat * tan2lat + + const I = M + N0 + const II = (nu / 2) * sinLat * cosLat + const III = (nu / 24) * sinLat * cos3lat * (5 - tan2lat + 9 * eta2) + const IIIA = (nu / 720) * sinLat * cos5lat * (61 - 58 * tan2lat + tan4lat) + const IV = nu * cosLat + const V = (nu / 6) * cos3lat * (nu / rho - tan2lat) + const VI = + (nu / 120) * + cos5lat * + (5 - 18 * tan2lat + tan4lat + 14 * eta2 - 58 * tan2lat * eta2) + + const dLon = lon - lon0 + const dLon2 = dLon * dLon + const dLon3 = dLon2 * dLon + const dLon4 = dLon3 * dLon + const dLon5 = dLon4 * dLon + const dLon6 = dLon5 * dLon + + const N = I + II * dLon2 + III * dLon4 + IIIA * dLon6 + const E = E0 + IV * dLon + V * dLon3 + VI * dLon5 + + return { easting: E, northing: N } +} + +/** + * + * @param {*} value + * @returns + */ +function toRad(value) { + return (value * Math.PI) / 180 +} + +/** + * + * @param {*} value + * @returns + */ +function toDeg(value) { + return (value * 180) / Math.PI +} From f37f9c0a369cf69589dbbcbb26405568453f94c3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 15 Jan 2026 15:01:33 +0000 Subject: [PATCH 3/5] Remove unused osgrid --- src/client/javascripts/osgrid.js | 164 ------------------------------- 1 file changed, 164 deletions(-) delete mode 100644 src/client/javascripts/osgrid.js diff --git a/src/client/javascripts/osgrid.js b/src/client/javascripts/osgrid.js deleted file mode 100644 index 41d432003..000000000 --- a/src/client/javascripts/osgrid.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * - * @param {*} gridref - * @returns - */ -export function osGridToLatLong(gridref) { - const E = gridref.easting - const N = gridref.northing - - const a = 6377563.396 - const b = 6356256.91 // Airy 1830 major & minor semi-axes - const F0 = 0.9996012717 // NatGrid scale factor on central meridian - const lat0 = (49 * Math.PI) / 180 - const lon0 = (-2 * Math.PI) / 180 // NatGrid true origin - const N0 = -100000 - const E0 = 400000 // northing & easting of true origin, metres - const e2 = 1 - (b * b) / (a * a) // eccentricity squared - const n = (a - b) / (a + b) - const n2 = n * n - const n3 = n * n * n - - let lat = lat0 - let M = 0 - do { - lat = (N - N0 - M) / (a * F0) + lat - - const Ma = (1 + n + (5 / 4) * n2 + (5 / 4) * n3) * (lat - lat0) - const Mb = - (3 * n + 3 * n * n + (21 / 8) * n3) * - Math.sin(lat - lat0) * - Math.cos(lat + lat0) - const Mc = - ((15 / 8) * n2 + (15 / 8) * n3) * - Math.sin(2 * (lat - lat0)) * - Math.cos(2 * (lat + lat0)) - const Md = - (35 / 24) * n3 * Math.sin(3 * (lat - lat0)) * Math.cos(3 * (lat + lat0)) - M = b * F0 * (Ma - Mb + Mc - Md) // meridional arc - } while (N - N0 - M >= 0.00001) // ie until < 0.01mm - - const cosLat = Math.cos(lat) - const sinLat = Math.sin(lat) - const nu = (a * F0) / Math.sqrt(1 - e2 * sinLat * sinLat) // transverse radius of curvature - const rho = (a * F0 * (1 - e2)) / Math.pow(1 - e2 * sinLat * sinLat, 1.5) // meridional radius of curvature - const eta2 = nu / rho - 1 - - const tanLat = Math.tan(lat) - const tan2lat = tanLat * tanLat - const tan4lat = tan2lat * tan2lat - const tan6lat = tan4lat * tan2lat - const secLat = 1 / cosLat - const nu3 = nu * nu * nu - const nu5 = nu3 * nu * nu - const nu7 = nu5 * nu * nu - const VII = tanLat / (2 * rho * nu) - const VIII = - (tanLat / (24 * rho * nu3)) * (5 + 3 * tan2lat + eta2 - 9 * tan2lat * eta2) - const IX = (tanLat / (720 * rho * nu5)) * (61 + 90 * tan2lat + 45 * tan4lat) - const X = secLat / nu - const XI = (secLat / (6 * nu3)) * (nu / rho + 2 * tan2lat) - const XII = (secLat / (120 * nu5)) * (5 + 28 * tan2lat + 24 * tan4lat) - const XIIA = - (secLat / (5040 * nu7)) * - (61 + 662 * tan2lat + 1320 * tan4lat + 720 * tan6lat) - - const dE = E - E0 - const dE2 = dE * dE - const dE3 = dE2 * dE - const dE4 = dE2 * dE2 - const dE5 = dE3 * dE2 - const dE6 = dE4 * dE2 - const dE7 = dE5 * dE2 - lat = lat - VII * dE2 + VIII * dE4 - IX * dE6 - const lon = lon0 + X * dE - XI * dE3 + XII * dE5 - XIIA * dE7 - - return { lat: toDeg(lat), long: toDeg(lon) } -} - -/** - * - * @param {*} point - * @returns - */ -export function latLongToOsGrid(point) { - const lat = toRad(point.lat) - const lon = toRad(point.long) - - const a = 6377563.396 - const b = 6356256.91 // Airy 1830 major & minor semi-axes - const F0 = 0.9996012717 // NatGrid scale factor on central meridian - const lat0 = toRad(49) - const lon0 = toRad(-2) // NatGrid true origin is 49ºN,2ºW - const N0 = -100000 - const E0 = 400000 // northing & easting of true origin, metres - const e2 = 1 - (b * b) / (a * a) // eccentricity squared - const n = (a - b) / (a + b) - const n2 = n * n - const n3 = n * n * n - - const cosLat = Math.cos(lat) - const sinLat = Math.sin(lat) - const nu = (a * F0) / Math.sqrt(1 - e2 * sinLat * sinLat) // transverse radius of curvature - const rho = (a * F0 * (1 - e2)) / Math.pow(1 - e2 * sinLat * sinLat, 1.5) // meridional radius of curvature - const eta2 = nu / rho - 1 - - const Ma = (1 + n + (5 / 4) * n2 + (5 / 4) * n3) * (lat - lat0) - const Mb = - (3 * n + 3 * n * n + (21 / 8) * n3) * - Math.sin(lat - lat0) * - Math.cos(lat + lat0) - const Mc = - ((15 / 8) * n2 + (15 / 8) * n3) * - Math.sin(2 * (lat - lat0)) * - Math.cos(2 * (lat + lat0)) - const Md = - (35 / 24) * n3 * Math.sin(3 * (lat - lat0)) * Math.cos(3 * (lat + lat0)) - const M = b * F0 * (Ma - Mb + Mc - Md) // meridional arc - - const cos3lat = cosLat * cosLat * cosLat - const cos5lat = cos3lat * cosLat * cosLat - const tan2lat = Math.tan(lat) * Math.tan(lat) - const tan4lat = tan2lat * tan2lat - - const I = M + N0 - const II = (nu / 2) * sinLat * cosLat - const III = (nu / 24) * sinLat * cos3lat * (5 - tan2lat + 9 * eta2) - const IIIA = (nu / 720) * sinLat * cos5lat * (61 - 58 * tan2lat + tan4lat) - const IV = nu * cosLat - const V = (nu / 6) * cos3lat * (nu / rho - tan2lat) - const VI = - (nu / 120) * - cos5lat * - (5 - 18 * tan2lat + tan4lat + 14 * eta2 - 58 * tan2lat * eta2) - - const dLon = lon - lon0 - const dLon2 = dLon * dLon - const dLon3 = dLon2 * dLon - const dLon4 = dLon3 * dLon - const dLon5 = dLon4 * dLon - const dLon6 = dLon5 * dLon - - const N = I + II * dLon2 + III * dLon4 + IIIA * dLon6 - const E = E0 + IV * dLon + V * dLon3 + VI * dLon5 - - return { easting: E, northing: N } -} - -/** - * - * @param {*} value - * @returns - */ -function toRad(value) { - return (value * Math.PI) / 180 -} - -/** - * - * @param {*} value - * @returns - */ -function toDeg(value) { - return (value * 180) / Math.PI -} From 4ae7fd86136beaf53ddc97fa5dfc1547e9c8ad34 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 15 Jan 2026 16:06:01 +0000 Subject: [PATCH 4/5] Add easting and northing tests location map client tests --- jest.config.cjs | 3 +- test/client/javascripts/location-map.test.js | 131 ++++++++++++++++++- 2 files changed, 126 insertions(+), 8 deletions(-) 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/test/client/javascripts/location-map.test.js b/test/client/javascripts/location-map.test.js index 4b7b02ac4..655d23d0e 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,11 +116,6 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) - // 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] }) - const inputs = document.body.querySelectorAll('input.govuk-input') expect(inputs).toHaveLength(2) @@ -133,9 +128,131 @@ describe('Location Maps Client JS', () => { longInput.value = '-2.421975' longInput.dispatchEvent(new window.Event('change')) + // Expect it to update twice as 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.1478238, 54.155676] }) + }) + + 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 only applies when there are location components on the page', () => { From bff24c72d81f053c0c0dd91756b45edc78b111ab Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 15 Jan 2026 16:58:40 +0000 Subject: [PATCH 5/5] Add map tests with initial field values --- test/client/javascripts/location-map.test.js | 94 +++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/test/client/javascripts/location-map.test.js b/test/client/javascripts/location-map.test.js index 655d23d0e..00bb8e0b3 100644 --- a/test/client/javascripts/location-map.test.js +++ b/test/client/javascripts/location-map.test.js @@ -128,7 +128,7 @@ describe('Location Maps Client JS', () => { longInput.value = '-2.421975' longInput.dispatchEvent(new window.Event('change')) - // Expect it to update twice as when both fields are valid + // Expect it to update once, only when both fields are valid expect(addMarkerMock).toHaveBeenCalledTimes(1) expect(flyToMock).toHaveBeenCalledTimes(1) @@ -138,6 +138,52 @@ describe('Location Maps Client JS', () => { 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') @@ -255,6 +301,52 @@ describe('Location Maps Client JS', () => { }) }) + 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', () => { const locations = document.querySelectorAll('.app-location-field')