Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
185 changes: 182 additions & 3 deletions src/client/javascripts/location-map.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
]
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading