From f809f0bec6baf3d1e7c671e7f695f289e8c83d5b Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:47:57 +0000
Subject: [PATCH 01/10] Rename check-boundary-result route to /quote/map
Rename the directory, route path, view path, and page title
from 'Check your boundary' to 'Boundary Map'.
Co-Authored-By: Claude Opus 4.6
---
src/client/javascripts/boundary-map.js | 2 +-
src/server/quote/index.js | 4 ++--
.../accessibility.test.js | 2 +-
.../quote/{check-boundary-result => map}/controller.js | 8 ++++----
.../{check-boundary-result => map}/controller.test.js | 10 +++++-----
.../{check-boundary-result => map}/form-validation.js | 0
.../form-validation.test.js | 2 +-
.../{check-boundary-result => map}/get-next-page.js | 0
.../get-next-page.test.js | 0
.../{check-boundary-result => map}/get-view-model.js | 2 +-
.../get-view-model.test.js | 2 +-
.../quote/{check-boundary-result => map}/index.njk | 0
.../quote/{check-boundary-result => map}/page.test.js | 6 +++---
.../quote/{check-boundary-result => map}/routes.js | 2 +-
src/server/quote/upload-received/controller.js | 2 +-
src/server/quote/upload-received/controller.test.js | 2 +-
16 files changed, 22 insertions(+), 22 deletions(-)
rename src/server/quote/{check-boundary-result => map}/accessibility.test.js (95%)
rename src/server/quote/{check-boundary-result => map}/controller.js (88%)
rename src/server/quote/{check-boundary-result => map}/controller.test.js (95%)
rename src/server/quote/{check-boundary-result => map}/form-validation.js (100%)
rename src/server/quote/{check-boundary-result => map}/form-validation.test.js (93%)
rename src/server/quote/{check-boundary-result => map}/get-next-page.js (100%)
rename src/server/quote/{check-boundary-result => map}/get-next-page.test.js (100%)
rename src/server/quote/{check-boundary-result => map}/get-view-model.js (95%)
rename src/server/quote/{check-boundary-result => map}/get-view-model.test.js (97%)
rename src/server/quote/{check-boundary-result => map}/index.njk (100%)
rename src/server/quote/{check-boundary-result => map}/page.test.js (98%)
rename src/server/quote/{check-boundary-result => map}/routes.js (94%)
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index cba7e01..e18ae0b 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -1,5 +1,5 @@
/**
- * Initialises the DEFRA interactive map on the check-boundary-result page,
+ * Initialises the DEFRA interactive map on the boundary map page,
* displaying the uploaded red line boundary.
*
* The backend returns geometry in WGS84 (EPSG:4326) via the `proj` query
diff --git a/src/server/quote/index.js b/src/server/quote/index.js
index bdb2db1..f3126f1 100644
--- a/src/server/quote/index.js
+++ b/src/server/quote/index.js
@@ -6,7 +6,7 @@ import routesDevelopmentType from './development-types/routes.js'
import routesEmail from './email/routes.js'
import routesUploadBoundary from './upload-boundary/routes.js'
import routesUploadReceived from './upload-received/routes.js'
-import routesCheckBoundaryResult from './check-boundary-result/routes.js'
+import routesMap from './map/routes.js'
import routesCheckYourAnswers from './check-your-answers/routes.js'
import routesConfirmation from './confirmation/routes.js'
import routesPeopleCount from './people-count/routes.js'
@@ -45,7 +45,7 @@ export const quote = {
...routesEmail,
...routesUploadBoundary,
...routesUploadReceived,
- ...routesCheckBoundaryResult,
+ ...routesMap,
...routesCheckYourAnswers,
...routesPeopleCount,
...routesConfirmation,
diff --git a/src/server/quote/check-boundary-result/accessibility.test.js b/src/server/quote/map/accessibility.test.js
similarity index 95%
rename from src/server/quote/check-boundary-result/accessibility.test.js
rename to src/server/quote/map/accessibility.test.js
index bf76679..7063d27 100644
--- a/src/server/quote/check-boundary-result/accessibility.test.js
+++ b/src/server/quote/map/accessibility.test.js
@@ -30,7 +30,7 @@ const mockGeojson = {
]
}
-describe('Check boundary result page accessibility checks', () => {
+describe('Boundary map page accessibility checks', () => {
const getServer = setupTestServer()
it('should have no HTML accessibility issues after an invalid form submission', async () => {
diff --git a/src/server/quote/check-boundary-result/controller.js b/src/server/quote/map/controller.js
similarity index 88%
rename from src/server/quote/check-boundary-result/controller.js
rename to src/server/quote/map/controller.js
index 607e468..0737fa1 100644
--- a/src/server/quote/check-boundary-result/controller.js
+++ b/src/server/quote/map/controller.js
@@ -8,7 +8,7 @@ import {
import { routePath as uploadBoundaryPath } from '../upload-boundary/routes.js'
import getViewModel from './get-view-model.js'
-const selfPath = '/quote/check-boundary-result'
+const selfPath = '/quote/map'
const logger = createLogger()
@@ -16,7 +16,7 @@ export function handler(request, h) {
const boundaryGeojson = request.yar.get('boundaryGeojson')
if (!boundaryGeojson) {
- logger.info('check-boundary-result - no boundary data in session')
+ logger.info('map - no boundary data in session')
return h.redirect(uploadBoundaryPath)
}
@@ -29,7 +29,7 @@ export function handler(request, h) {
const viewModel = getViewModel(boundaryGeojson)
- return h.view('quote/check-boundary-result/index', {
+ return h.view('quote/map/index', {
...viewModel,
formSubmitData: flash?.formSubmitData ?? {},
validationErrors
@@ -69,7 +69,7 @@ export function postHandler(request, h) {
saveQuoteDataToCache(request, { boundaryGeojson })
request.yar.clear('boundaryGeojson')
- logger.info('check-boundary-result - boundary confirmed, saved to quote data')
+ logger.info('map - boundary confirmed, saved to quote data')
return h.redirect('/quote/development-types')
}
diff --git a/src/server/quote/check-boundary-result/controller.test.js b/src/server/quote/map/controller.test.js
similarity index 95%
rename from src/server/quote/check-boundary-result/controller.test.js
rename to src/server/quote/map/controller.test.js
index 3f47ba3..0d5cb9c 100644
--- a/src/server/quote/check-boundary-result/controller.test.js
+++ b/src/server/quote/map/controller.test.js
@@ -17,7 +17,7 @@ const { saveQuoteDataToCache } =
const { getValidationFlashFromCache, saveValidationFlashToCache } =
await import('../helpers/form-validation-session/index.js')
-describe('check-boundary-result controller', () => {
+describe('map controller', () => {
const mockGeometry = {
type: 'FeatureCollection',
features: [{ type: 'Feature', geometry: { type: 'Polygon' } }]
@@ -74,9 +74,9 @@ describe('check-boundary-result controller', () => {
handler(request, h)
expect(h.view).toHaveBeenCalledWith(
- 'quote/check-boundary-result/index',
+ 'quote/map/index',
expect.objectContaining({
- pageHeading: 'Check your boundary',
+ pageHeading: 'Boundary Map',
featureCount: 1,
boundaryGeojson: JSON.stringify(mockGeometry),
formSubmitData: {},
@@ -103,7 +103,7 @@ describe('check-boundary-result controller', () => {
handler(request, h)
expect(h.view).toHaveBeenCalledWith(
- 'quote/check-boundary-result/index',
+ 'quote/map/index',
expect.objectContaining({
validationErrors: mockErrors.validationErrors,
formSubmitData: {}
@@ -165,7 +165,7 @@ describe('check-boundary-result controller', () => {
}),
formSubmitData: {}
})
- expect(h.redirect).toHaveBeenCalledWith('/quote/check-boundary-result')
+ expect(h.redirect).toHaveBeenCalledWith('/quote/map')
})
it('should save and redirect to development-types when boundary intersects EDP', () => {
diff --git a/src/server/quote/check-boundary-result/form-validation.js b/src/server/quote/map/form-validation.js
similarity index 100%
rename from src/server/quote/check-boundary-result/form-validation.js
rename to src/server/quote/map/form-validation.js
diff --git a/src/server/quote/check-boundary-result/form-validation.test.js b/src/server/quote/map/form-validation.test.js
similarity index 93%
rename from src/server/quote/check-boundary-result/form-validation.test.js
rename to src/server/quote/map/form-validation.test.js
index 8637277..2d22c09 100644
--- a/src/server/quote/check-boundary-result/form-validation.test.js
+++ b/src/server/quote/map/form-validation.test.js
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import getSchema from './form-validation.js'
-describe('check-boundary-result form validation', () => {
+describe('map form validation', () => {
describe('boundaryCorrect', () => {
it('passes for "yes"', () => {
const { error } = getSchema().validate({ boundaryCorrect: 'yes' })
diff --git a/src/server/quote/check-boundary-result/get-next-page.js b/src/server/quote/map/get-next-page.js
similarity index 100%
rename from src/server/quote/check-boundary-result/get-next-page.js
rename to src/server/quote/map/get-next-page.js
diff --git a/src/server/quote/check-boundary-result/get-next-page.test.js b/src/server/quote/map/get-next-page.test.js
similarity index 100%
rename from src/server/quote/check-boundary-result/get-next-page.test.js
rename to src/server/quote/map/get-next-page.test.js
diff --git a/src/server/quote/check-boundary-result/get-view-model.js b/src/server/quote/map/get-view-model.js
similarity index 95%
rename from src/server/quote/check-boundary-result/get-view-model.js
rename to src/server/quote/map/get-view-model.js
index d8c3cf9..b8ad328 100644
--- a/src/server/quote/check-boundary-result/get-view-model.js
+++ b/src/server/quote/map/get-view-model.js
@@ -3,7 +3,7 @@ import { routePath as uploadBoundaryPath } from '../upload-boundary/routes.js'
import { routePath as boundaryTypePath } from '../boundary-type/routes.js'
import { config } from '../../../config/config.js'
-export const title = 'Check your boundary'
+export const title = 'Boundary Map'
export default function getViewModel(boundaryGeojson) {
const geometry = boundaryGeojson?.geometry
diff --git a/src/server/quote/check-boundary-result/get-view-model.test.js b/src/server/quote/map/get-view-model.test.js
similarity index 97%
rename from src/server/quote/check-boundary-result/get-view-model.test.js
rename to src/server/quote/map/get-view-model.test.js
index abffc6e..9a94a90 100644
--- a/src/server/quote/check-boundary-result/get-view-model.test.js
+++ b/src/server/quote/map/get-view-model.test.js
@@ -10,7 +10,7 @@ describe('getViewModel', () => {
})
expect(result.featureCount).toBe(0)
- expect(result.pageHeading).toBe('Check your boundary')
+ expect(result.pageHeading).toBe('Boundary Map')
expect(result.boundaryGeojson).toBe(
JSON.stringify({ type: 'FeatureCollection' })
)
diff --git a/src/server/quote/check-boundary-result/index.njk b/src/server/quote/map/index.njk
similarity index 100%
rename from src/server/quote/check-boundary-result/index.njk
rename to src/server/quote/map/index.njk
diff --git a/src/server/quote/check-boundary-result/page.test.js b/src/server/quote/map/page.test.js
similarity index 98%
rename from src/server/quote/check-boundary-result/page.test.js
rename to src/server/quote/map/page.test.js
index 8c164ec..9f7a723 100644
--- a/src/server/quote/check-boundary-result/page.test.js
+++ b/src/server/quote/map/page.test.js
@@ -90,7 +90,7 @@ async function loadPageWithSession(server, geojson = mockGeojson) {
return { document: window.document, cookie }
}
-describe('Check boundary result page', () => {
+describe('Boundary map page', () => {
const getServer = setupTestServer()
describe('when boundary does not intersect EDP', () => {
@@ -98,10 +98,10 @@ describe('Check boundary result page', () => {
const { document } = await loadPageWithSession(getServer())
expect(getByRole(document, 'heading', { level: 1 })).toHaveTextContent(
- 'Check your boundary'
+ 'Boundary Map'
)
expect(document.title).toBe(
- 'Check your boundary - Nature Restoration Fund - Gov.uk'
+ 'Boundary Map - Nature Restoration Fund - Gov.uk'
)
expect(getByRole(document, 'link', { name: 'Back' })).toHaveAttribute(
'href',
diff --git a/src/server/quote/check-boundary-result/routes.js b/src/server/quote/map/routes.js
similarity index 94%
rename from src/server/quote/check-boundary-result/routes.js
rename to src/server/quote/map/routes.js
index c2fbbef..5c74f01 100644
--- a/src/server/quote/check-boundary-result/routes.js
+++ b/src/server/quote/map/routes.js
@@ -4,7 +4,7 @@ import { saveValidationFlashToCache } from '../helpers/form-validation-session/i
import { statusCodes } from '../../common/constants/status-codes.js'
import formValidation from './form-validation.js'
-export const routePath = '/quote/check-boundary-result'
+export const routePath = '/quote/map'
export default [
{
diff --git a/src/server/quote/upload-received/controller.js b/src/server/quote/upload-received/controller.js
index 1c7d777..cf5d13c 100644
--- a/src/server/quote/upload-received/controller.js
+++ b/src/server/quote/upload-received/controller.js
@@ -67,5 +67,5 @@ export async function checkBoundaryHandler(request, h) {
request.yar.set('boundaryGeojson', result.geojson)
request.yar.clear('pendingUploadId')
- return h.redirect('/quote/check-boundary-result')
+ return h.redirect('/quote/map')
}
diff --git a/src/server/quote/upload-received/controller.test.js b/src/server/quote/upload-received/controller.test.js
index 20ddaf4..d2efccf 100644
--- a/src/server/quote/upload-received/controller.test.js
+++ b/src/server/quote/upload-received/controller.test.js
@@ -149,7 +149,7 @@ describe('checkBoundaryHandler', () => {
expect(checkBoundary).toHaveBeenCalledWith('test-upload-id')
expect(request.yar.set).toHaveBeenCalledWith('boundaryGeojson', mockGeojson)
expect(request.yar.clear).toHaveBeenCalledWith('pendingUploadId')
- expect(h.redirect).toHaveBeenCalledWith('/quote/check-boundary-result')
+ expect(h.redirect).toHaveBeenCalledWith('/quote/map')
})
it('should render error page when boundary check fails', async () => {
From d15833c319fded16b51cb869141342a3369a3b69 Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:57:19 +0000
Subject: [PATCH 02/10] Add EDP intersection geometry display to boundary map
- Build edpIntersectionGeojson FeatureCollection in view model
- Add EDP intersection overlay layer (blue fill + dashed line) to map
- Show overlap area and percentage under each EDP name in template
- Update controller test fixtures with intersection geometry fields
Co-Authored-By: Claude Opus 4.6
---
src/client/javascripts/boundary-map.js | 48 ++++++++++
src/client/javascripts/boundary-map.test.js | 99 ++++++++++++++++++++-
src/server/quote/map/controller.test.js | 22 ++++-
src/server/quote/map/get-view-model.js | 16 ++++
src/server/quote/map/get-view-model.test.js | 55 ++++++++++++
src/server/quote/map/index.njk | 10 ++-
6 files changed, 247 insertions(+), 3 deletions(-)
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index e18ae0b..670efbf 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -95,6 +95,50 @@ function addBoundaryLayer(mapInstance, geojson) {
fitMapToBounds(mapInstance, geojson)
}
+function addEdpIntersectionLayer(mapInstance, edpGeojson) {
+ if (!edpGeojson || !edpGeojson.features || !edpGeojson.features.length) {
+ return
+ }
+
+ if (mapInstance.getSource('edp-intersection')) {
+ return
+ }
+
+ mapInstance.addSource('edp-intersection', {
+ type: 'geojson',
+ data: edpGeojson
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-intersection-fill',
+ type: 'fill',
+ source: 'edp-intersection',
+ paint: {
+ 'fill-color': '#1d70b8',
+ 'fill-opacity': 0.3
+ }
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-intersection-line',
+ type: 'line',
+ source: 'edp-intersection',
+ paint: {
+ 'line-color': '#1d70b8',
+ 'line-width': 2,
+ 'line-dasharray': [4, 2]
+ }
+ })
+}
+
+function parseEdpGeojson(mapEl) {
+ try {
+ return JSON.parse(mapEl.dataset.edpIntersectionGeojson)
+ } catch (e) {
+ return null
+ }
+}
+
function initBoundaryMap() {
const mapEl = document.getElementById('boundary-map')
if (!mapEl) {
@@ -106,6 +150,8 @@ function initBoundaryMap() {
return
}
+ const edpGeojson = parseEdpGeojson(mapEl)
+
if (
typeof defra === 'undefined' ||
!defra.InteractiveMap ||
@@ -133,9 +179,11 @@ function initBoundaryMap() {
if (mapInstance.isStyleLoaded()) {
addBoundaryLayer(mapInstance, geojson)
+ addEdpIntersectionLayer(mapInstance, edpGeojson)
} else {
mapInstance.once('style.load', function () {
addBoundaryLayer(mapInstance, geojson)
+ addEdpIntersectionLayer(mapInstance, edpGeojson)
})
}
})
diff --git a/src/client/javascripts/boundary-map.test.js b/src/client/javascripts/boundary-map.test.js
index 708d7b6..7a27609 100644
--- a/src/client/javascripts/boundary-map.test.js
+++ b/src/client/javascripts/boundary-map.test.js
@@ -23,9 +23,36 @@ const validGeojson = {
]
}
+const validEdpGeojson = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.5, 52.0],
+ [-1.45, 52.0],
+ [-1.45, 52.05],
+ [-1.5, 52.05],
+ [-1.5, 52.0]
+ ]
+ ]
+ },
+ properties: {
+ label: 'EDP 1',
+ overlap_area_ha: 0.5,
+ overlap_percentage: 25.0
+ }
+ }
+ ]
+}
+
function createMapElement(
geojson,
- styleUrl = 'https://example.com/style.json'
+ styleUrl = 'https://example.com/style.json',
+ edpGeojson = undefined
) {
const el = document.createElement('div')
el.id = 'boundary-map'
@@ -33,6 +60,10 @@ function createMapElement(
el.dataset.geojson =
typeof geojson === 'string' ? geojson : JSON.stringify(geojson)
}
+ if (edpGeojson !== undefined) {
+ el.dataset.edpIntersectionGeojson =
+ typeof edpGeojson === 'string' ? edpGeojson : JSON.stringify(edpGeojson)
+ }
el.dataset.mapStyleUrl = styleUrl
document.body.appendChild(el)
return el
@@ -323,4 +354,70 @@ describe('boundary-map', () => {
expect(mapInstance.fitBounds).not.toHaveBeenCalled()
})
+
+ it('adds EDP intersection layers when edp geojson is provided', async () => {
+ createMapElement(
+ validGeojson,
+ 'https://example.com/style.json',
+ validEdpGeojson
+ )
+ const mapInstance = createMockMapInstance(true)
+ const mockDefra = createMockDefra(mapInstance)
+ globalThis.defra = mockDefra
+
+ await loadModule()
+ mockDefra._triggerReady()
+
+ expect(mapInstance.addSource).toHaveBeenCalledWith('edp-intersection', {
+ type: 'geojson',
+ data: validEdpGeojson
+ })
+ // boundary (2) + edp intersection (2) = 4 layers
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(4)
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-intersection-fill',
+ type: 'fill',
+ source: 'edp-intersection'
+ })
+ )
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-intersection-line',
+ type: 'line',
+ source: 'edp-intersection'
+ })
+ )
+ })
+
+ it('does not add EDP intersection layers when no edp geojson is provided', async () => {
+ createMapElement(validGeojson)
+ const mapInstance = createMockMapInstance(true)
+ const mockDefra = createMockDefra(mapInstance)
+ globalThis.defra = mockDefra
+
+ await loadModule()
+ mockDefra._triggerReady()
+
+ expect(mapInstance.addSource).toHaveBeenCalledTimes(1)
+ expect(mapInstance.addSource).toHaveBeenCalledWith(
+ 'boundary',
+ expect.any(Object)
+ )
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not add EDP intersection layers when edp geojson has empty features', async () => {
+ const emptyEdp = { type: 'FeatureCollection', features: [] }
+ createMapElement(validGeojson, 'https://example.com/style.json', emptyEdp)
+ const mapInstance = createMockMapInstance(true)
+ const mockDefra = createMockDefra(mapInstance)
+ globalThis.defra = mockDefra
+
+ await loadModule()
+ mockDefra._triggerReady()
+
+ expect(mapInstance.addSource).toHaveBeenCalledTimes(1)
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/server/quote/map/controller.test.js b/src/server/quote/map/controller.test.js
index 0d5cb9c..39ea17e 100644
--- a/src/server/quote/map/controller.test.js
+++ b/src/server/quote/map/controller.test.js
@@ -31,7 +31,27 @@ describe('map controller', () => {
const mockEdpGeojson = {
geometry: mockGeometry,
- intersecting_edps: [{ label: 'EDP 1', n2k_site_name: 'Site 1' }],
+ intersecting_edps: [
+ {
+ label: 'EDP 1',
+ n2k_site_name: 'Site 1',
+ intersection_geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.5, 52.0],
+ [-1.4, 52.0],
+ [-1.4, 52.1],
+ [-1.5, 52.1],
+ [-1.5, 52.0]
+ ]
+ ]
+ },
+ overlap_area_ha: 0.5,
+ overlap_area_sqm: 5000.0,
+ overlap_percentage: 25.0
+ }
+ ],
intersects_edp: true
}
diff --git a/src/server/quote/map/get-view-model.js b/src/server/quote/map/get-view-model.js
index b8ad328..56ea104 100644
--- a/src/server/quote/map/get-view-model.js
+++ b/src/server/quote/map/get-view-model.js
@@ -11,12 +11,28 @@ export default function getViewModel(boundaryGeojson) {
const intersectsEdp = boundaryGeojson?.intersects_edp ?? false
const featureCount = geometry?.features?.length ?? 0
+ const edpIntersectionGeojson = JSON.stringify({
+ type: 'FeatureCollection',
+ features: intersectingEdps
+ .filter((edp) => edp.intersection_geometry)
+ .map((edp) => ({
+ type: 'Feature',
+ geometry: edp.intersection_geometry,
+ properties: {
+ label: edp.label,
+ overlap_area_ha: edp.overlap_area_ha,
+ overlap_percentage: edp.overlap_percentage
+ }
+ }))
+ })
+
return {
pageTitle: getPageTitle(title),
pageHeading: title,
boundaryGeojson: JSON.stringify(geometry),
intersectingEdps,
intersectsEdp,
+ edpIntersectionGeojson,
boundaryResponseJson: JSON.stringify(boundaryGeojson, null, 2),
featureCount,
backLinkPath: uploadBoundaryPath,
diff --git a/src/server/quote/map/get-view-model.test.js b/src/server/quote/map/get-view-model.test.js
index 9a94a90..ac280fb 100644
--- a/src/server/quote/map/get-view-model.test.js
+++ b/src/server/quote/map/get-view-model.test.js
@@ -68,4 +68,59 @@ describe('getViewModel', () => {
expect(result.cancelPath).toBe('/quote/boundary-type')
expect(result.mapStyleUrl).toBeDefined()
})
+
+ it('should build edpIntersectionGeojson from enhanced EDP data', () => {
+ const edps = [
+ {
+ label: 'EDP Area 1',
+ n2k_site_name: 'Site A',
+ intersection_geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.5, 52.0],
+ [-1.4, 52.0],
+ [-1.4, 52.1],
+ [-1.5, 52.1],
+ [-1.5, 52.0]
+ ]
+ ]
+ },
+ overlap_area_ha: 0.5,
+ overlap_area_sqm: 5000.0,
+ overlap_percentage: 25.0
+ }
+ ]
+ const response = {
+ geometry: { type: 'FeatureCollection', features: [] },
+ intersecting_edps: edps,
+ intersects_edp: true
+ }
+
+ const result = getViewModel(response)
+ const parsed = JSON.parse(result.edpIntersectionGeojson)
+
+ expect(parsed.type).toBe('FeatureCollection')
+ expect(parsed.features).toHaveLength(1)
+ expect(parsed.features[0].type).toBe('Feature')
+ expect(parsed.features[0].geometry.type).toBe('Polygon')
+ expect(parsed.features[0].properties.label).toBe('EDP Area 1')
+ expect(parsed.features[0].properties.overlap_area_ha).toBe(0.5)
+ expect(parsed.features[0].properties.overlap_percentage).toBe(25.0)
+ })
+
+ it('should return empty FeatureCollection when no EDPs have intersection geometry', () => {
+ const edps = [{ label: 'EDP Area 1', n2k_site_name: 'Site A' }]
+ const response = {
+ geometry: { type: 'FeatureCollection', features: [] },
+ intersecting_edps: edps,
+ intersects_edp: true
+ }
+
+ const result = getViewModel(response)
+ const parsed = JSON.parse(result.edpIntersectionGeojson)
+
+ expect(parsed.type).toBe('FeatureCollection')
+ expect(parsed.features).toHaveLength(0)
+ })
})
diff --git a/src/server/quote/map/index.njk b/src/server/quote/map/index.njk
index fe37582..978627a 100644
--- a/src/server/quote/map/index.njk
+++ b/src/server/quote/map/index.njk
@@ -31,6 +31,7 @@
Map view is not available. The boundary data has been validated.
@@ -50,7 +51,14 @@
{% for edp in intersectingEdps %}
- - {{ edp.label }}{% if edp.n2k_site_name %} ({{ edp.n2k_site_name }}){% endif %}
+ -
+ {{ edp.label }}{% if edp.n2k_site_name %} ({{ edp.n2k_site_name }}){% endif %}
+ {% if edp.overlap_area_ha %}
+
+ Overlap: {{ edp.overlap_area_ha }} ha ({{ edp.overlap_percentage }}% of boundary)
+
+ {% endif %}
+
{% endfor %}
From 620aae1814155e75c7e7b39d6de42afa3d9415c6 Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Tue, 17 Mar 2026 14:48:40 +0000
Subject: [PATCH 03/10] Show intersected EDP polygon layer on map
---
src/client/javascripts/boundary-map.js | 58 ++++++++++++++-
src/client/javascripts/boundary-map.test.js | 82 ++++++++++++++++-----
src/server/quote/map/get-view-model.js | 13 +++-
src/server/quote/map/get-view-model.test.js | 69 ++++++++++-------
src/server/quote/map/index.njk | 10 +--
5 files changed, 174 insertions(+), 58 deletions(-)
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index 670efbf..dcd7187 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -95,6 +95,45 @@ function addBoundaryLayer(mapInstance, geojson) {
fitMapToBounds(mapInstance, geojson)
}
+function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
+ if (
+ !edpBoundaryGeojson ||
+ !edpBoundaryGeojson.features ||
+ !edpBoundaryGeojson.features.length
+ ) {
+ return
+ }
+
+ if (mapInstance.getSource('edp-boundary')) {
+ return
+ }
+
+ mapInstance.addSource('edp-boundary', {
+ type: 'geojson',
+ data: edpBoundaryGeojson
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-boundary-fill',
+ type: 'fill',
+ source: 'edp-boundary',
+ paint: {
+ 'fill-color': '#00703c',
+ 'fill-opacity': 0.08
+ }
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-boundary-line',
+ type: 'line',
+ source: 'edp-boundary',
+ paint: {
+ 'line-color': '#00703c',
+ 'line-width': 2
+ }
+ })
+}
+
function addEdpIntersectionLayer(mapInstance, edpGeojson) {
if (!edpGeojson || !edpGeojson.features || !edpGeojson.features.length) {
return
@@ -131,7 +170,15 @@ function addEdpIntersectionLayer(mapInstance, edpGeojson) {
})
}
-function parseEdpGeojson(mapEl) {
+function parseEdpBoundaryGeojson(mapEl) {
+ try {
+ return JSON.parse(mapEl.dataset.edpBoundaryGeojson)
+ } catch (e) {
+ return null
+ }
+}
+
+function parseEdpIntersectionGeojson(mapEl) {
try {
return JSON.parse(mapEl.dataset.edpIntersectionGeojson)
} catch (e) {
@@ -150,7 +197,8 @@ function initBoundaryMap() {
return
}
- const edpGeojson = parseEdpGeojson(mapEl)
+ const edpBoundaryGeojson = parseEdpBoundaryGeojson(mapEl)
+ const edpIntersectionGeojson = parseEdpIntersectionGeojson(mapEl)
if (
typeof defra === 'undefined' ||
@@ -179,11 +227,13 @@ function initBoundaryMap() {
if (mapInstance.isStyleLoaded()) {
addBoundaryLayer(mapInstance, geojson)
- addEdpIntersectionLayer(mapInstance, edpGeojson)
+ addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson)
+ addEdpIntersectionLayer(mapInstance, edpIntersectionGeojson)
} else {
mapInstance.once('style.load', function () {
addBoundaryLayer(mapInstance, geojson)
- addEdpIntersectionLayer(mapInstance, edpGeojson)
+ addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson)
+ addEdpIntersectionLayer(mapInstance, edpIntersectionGeojson)
})
}
})
diff --git a/src/client/javascripts/boundary-map.test.js b/src/client/javascripts/boundary-map.test.js
index 7a27609..fb4341d 100644
--- a/src/client/javascripts/boundary-map.test.js
+++ b/src/client/javascripts/boundary-map.test.js
@@ -23,7 +23,29 @@ const validGeojson = {
]
}
-const validEdpGeojson = {
+const validEdpBoundaryGeojson = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.6, 51.9],
+ [-1.3, 51.9],
+ [-1.3, 52.2],
+ [-1.6, 52.2],
+ [-1.6, 51.9]
+ ]
+ ]
+ },
+ properties: { label: 'EDP 1' }
+ }
+ ]
+}
+
+const validEdpIntersectionGeojson = {
type: 'FeatureCollection',
features: [
{
@@ -52,7 +74,7 @@ const validEdpGeojson = {
function createMapElement(
geojson,
styleUrl = 'https://example.com/style.json',
- edpGeojson = undefined
+ { edpBoundary, edpIntersection } = {}
) {
const el = document.createElement('div')
el.id = 'boundary-map'
@@ -60,9 +82,17 @@ function createMapElement(
el.dataset.geojson =
typeof geojson === 'string' ? geojson : JSON.stringify(geojson)
}
- if (edpGeojson !== undefined) {
+ if (edpBoundary !== undefined) {
+ el.dataset.edpBoundaryGeojson =
+ typeof edpBoundary === 'string'
+ ? edpBoundary
+ : JSON.stringify(edpBoundary)
+ }
+ if (edpIntersection !== undefined) {
el.dataset.edpIntersectionGeojson =
- typeof edpGeojson === 'string' ? edpGeojson : JSON.stringify(edpGeojson)
+ typeof edpIntersection === 'string'
+ ? edpIntersection
+ : JSON.stringify(edpIntersection)
}
el.dataset.mapStyleUrl = styleUrl
document.body.appendChild(el)
@@ -355,12 +385,11 @@ describe('boundary-map', () => {
expect(mapInstance.fitBounds).not.toHaveBeenCalled()
})
- it('adds EDP intersection layers when edp geojson is provided', async () => {
- createMapElement(
- validGeojson,
- 'https://example.com/style.json',
- validEdpGeojson
- )
+ it('adds EDP boundary and intersection layers when geojson is provided', async () => {
+ createMapElement(validGeojson, 'https://example.com/style.json', {
+ edpBoundary: validEdpBoundaryGeojson,
+ edpIntersection: validEdpIntersectionGeojson
+ })
const mapInstance = createMockMapInstance(true)
const mockDefra = createMockDefra(mapInstance)
globalThis.defra = mockDefra
@@ -368,29 +397,43 @@ describe('boundary-map', () => {
await loadModule()
mockDefra._triggerReady()
+ expect(mapInstance.addSource).toHaveBeenCalledWith('edp-boundary', {
+ type: 'geojson',
+ data: validEdpBoundaryGeojson
+ })
expect(mapInstance.addSource).toHaveBeenCalledWith('edp-intersection', {
type: 'geojson',
- data: validEdpGeojson
+ data: validEdpIntersectionGeojson
})
- // boundary (2) + edp intersection (2) = 4 layers
- expect(mapInstance.addLayer).toHaveBeenCalledTimes(4)
+ // boundary (2) + edp boundary (2) + edp intersection (2) = 6 layers
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(6)
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-boundary-fill',
+ source: 'edp-boundary'
+ })
+ )
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-boundary-line',
+ source: 'edp-boundary'
+ })
+ )
expect(mapInstance.addLayer).toHaveBeenCalledWith(
expect.objectContaining({
id: 'edp-intersection-fill',
- type: 'fill',
source: 'edp-intersection'
})
)
expect(mapInstance.addLayer).toHaveBeenCalledWith(
expect.objectContaining({
id: 'edp-intersection-line',
- type: 'line',
source: 'edp-intersection'
})
)
})
- it('does not add EDP intersection layers when no edp geojson is provided', async () => {
+ it('does not add EDP layers when no edp geojson is provided', async () => {
createMapElement(validGeojson)
const mapInstance = createMockMapInstance(true)
const mockDefra = createMockDefra(mapInstance)
@@ -407,9 +450,12 @@ describe('boundary-map', () => {
expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
})
- it('does not add EDP intersection layers when edp geojson has empty features', async () => {
+ it('does not add EDP layers when edp geojson has empty features', async () => {
const emptyEdp = { type: 'FeatureCollection', features: [] }
- createMapElement(validGeojson, 'https://example.com/style.json', emptyEdp)
+ createMapElement(validGeojson, 'https://example.com/style.json', {
+ edpBoundary: emptyEdp,
+ edpIntersection: emptyEdp
+ })
const mapInstance = createMockMapInstance(true)
const mockDefra = createMockDefra(mapInstance)
globalThis.defra = mockDefra
diff --git a/src/server/quote/map/get-view-model.js b/src/server/quote/map/get-view-model.js
index 56ea104..99c6286 100644
--- a/src/server/quote/map/get-view-model.js
+++ b/src/server/quote/map/get-view-model.js
@@ -11,6 +11,17 @@ export default function getViewModel(boundaryGeojson) {
const intersectsEdp = boundaryGeojson?.intersects_edp ?? false
const featureCount = geometry?.features?.length ?? 0
+ const edpBoundaryGeojson = JSON.stringify({
+ type: 'FeatureCollection',
+ features: intersectingEdps
+ .filter((edp) => edp.edp_geometry)
+ .map((edp) => ({
+ type: 'Feature',
+ geometry: edp.edp_geometry,
+ properties: { label: edp.label }
+ }))
+ })
+
const edpIntersectionGeojson = JSON.stringify({
type: 'FeatureCollection',
features: intersectingEdps
@@ -32,8 +43,8 @@ export default function getViewModel(boundaryGeojson) {
boundaryGeojson: JSON.stringify(geometry),
intersectingEdps,
intersectsEdp,
+ edpBoundaryGeojson,
edpIntersectionGeojson,
- boundaryResponseJson: JSON.stringify(boundaryGeojson, null, 2),
featureCount,
backLinkPath: uploadBoundaryPath,
uploadBoundaryPath,
diff --git a/src/server/quote/map/get-view-model.test.js b/src/server/quote/map/get-view-model.test.js
index ac280fb..4974e7e 100644
--- a/src/server/quote/map/get-view-model.test.js
+++ b/src/server/quote/map/get-view-model.test.js
@@ -69,23 +69,37 @@ describe('getViewModel', () => {
expect(result.mapStyleUrl).toBeDefined()
})
- it('should build edpIntersectionGeojson from enhanced EDP data', () => {
+ it('should build edpBoundaryGeojson and edpIntersectionGeojson from enhanced EDP data', () => {
+ const edpGeometry = {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.6, 51.9],
+ [-1.3, 51.9],
+ [-1.3, 52.2],
+ [-1.6, 52.2],
+ [-1.6, 51.9]
+ ]
+ ]
+ }
+ const intersectionGeometry = {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.5, 52.0],
+ [-1.4, 52.0],
+ [-1.4, 52.1],
+ [-1.5, 52.1],
+ [-1.5, 52.0]
+ ]
+ ]
+ }
const edps = [
{
label: 'EDP Area 1',
n2k_site_name: 'Site A',
- intersection_geometry: {
- type: 'Polygon',
- coordinates: [
- [
- [-1.5, 52.0],
- [-1.4, 52.0],
- [-1.4, 52.1],
- [-1.5, 52.1],
- [-1.5, 52.0]
- ]
- ]
- },
+ edp_geometry: edpGeometry,
+ intersection_geometry: intersectionGeometry,
overlap_area_ha: 0.5,
overlap_area_sqm: 5000.0,
overlap_percentage: 25.0
@@ -98,18 +112,22 @@ describe('getViewModel', () => {
}
const result = getViewModel(response)
- const parsed = JSON.parse(result.edpIntersectionGeojson)
-
- expect(parsed.type).toBe('FeatureCollection')
- expect(parsed.features).toHaveLength(1)
- expect(parsed.features[0].type).toBe('Feature')
- expect(parsed.features[0].geometry.type).toBe('Polygon')
- expect(parsed.features[0].properties.label).toBe('EDP Area 1')
- expect(parsed.features[0].properties.overlap_area_ha).toBe(0.5)
- expect(parsed.features[0].properties.overlap_percentage).toBe(25.0)
+
+ const boundary = JSON.parse(result.edpBoundaryGeojson)
+ expect(boundary.type).toBe('FeatureCollection')
+ expect(boundary.features).toHaveLength(1)
+ expect(boundary.features[0].geometry).toEqual(edpGeometry)
+ expect(boundary.features[0].properties.label).toBe('EDP Area 1')
+
+ const intersection = JSON.parse(result.edpIntersectionGeojson)
+ expect(intersection.type).toBe('FeatureCollection')
+ expect(intersection.features).toHaveLength(1)
+ expect(intersection.features[0].geometry).toEqual(intersectionGeometry)
+ expect(intersection.features[0].properties.overlap_area_ha).toBe(0.5)
+ expect(intersection.features[0].properties.overlap_percentage).toBe(25.0)
})
- it('should return empty FeatureCollection when no EDPs have intersection geometry', () => {
+ it('should return empty FeatureCollections when no EDPs have geometries', () => {
const edps = [{ label: 'EDP Area 1', n2k_site_name: 'Site A' }]
const response = {
geometry: { type: 'FeatureCollection', features: [] },
@@ -118,9 +136,8 @@ describe('getViewModel', () => {
}
const result = getViewModel(response)
- const parsed = JSON.parse(result.edpIntersectionGeojson)
- expect(parsed.type).toBe('FeatureCollection')
- expect(parsed.features).toHaveLength(0)
+ expect(JSON.parse(result.edpBoundaryGeojson).features).toHaveLength(0)
+ expect(JSON.parse(result.edpIntersectionGeojson).features).toHaveLength(0)
})
})
diff --git a/src/server/quote/map/index.njk b/src/server/quote/map/index.njk
index 978627a..d5c469b 100644
--- a/src/server/quote/map/index.njk
+++ b/src/server/quote/map/index.njk
@@ -31,20 +31,12 @@
Map view is not available. The boundary data has been validated.
-
-
- View boundary GeoJSON
-
-
-
{{ boundaryResponseJson }}
-
-
-
{% if intersectsEdp %}
Your red line boundary is within {{ intersectingEdps | length }} EDP{{ "s" if intersectingEdps | length != 1 }}:
From 3d346ef2f56b00e9259f31cee0b3f78b13cdcb6f Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Tue, 17 Mar 2026 14:59:19 +0000
Subject: [PATCH 04/10] Fix sonarqube issues
---
src/client/javascripts/boundary-map.js | 36 +++++++++++++-------------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index dcd7187..3ddf28f 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -8,6 +8,10 @@
/* global defra */
+var SOURCE_BOUNDARY = 'boundary'
+var SOURCE_EDP_BOUNDARY = 'edp-boundary'
+var SOURCE_EDP_INTERSECTION = 'edp-intersection'
+
function parseGeojson(mapEl) {
try {
return JSON.parse(mapEl.dataset.geojson)
@@ -63,11 +67,11 @@ function fitMapToBounds(mapInstance, geojson) {
}
function addBoundaryLayer(mapInstance, geojson) {
- if (mapInstance.getSource('boundary')) {
+ if (mapInstance.getSource(SOURCE_BOUNDARY)) {
return
}
- mapInstance.addSource('boundary', {
+ mapInstance.addSource(SOURCE_BOUNDARY, {
type: 'geojson',
data: geojson
})
@@ -75,7 +79,7 @@ function addBoundaryLayer(mapInstance, geojson) {
mapInstance.addLayer({
id: 'boundary-fill',
type: 'fill',
- source: 'boundary',
+ source: SOURCE_BOUNDARY,
paint: {
'fill-color': '#d4351c',
'fill-opacity': 0.1
@@ -85,7 +89,7 @@ function addBoundaryLayer(mapInstance, geojson) {
mapInstance.addLayer({
id: 'boundary-line',
type: 'line',
- source: 'boundary',
+ source: SOURCE_BOUNDARY,
paint: {
'line-color': '#d4351c',
'line-width': 3
@@ -96,19 +100,15 @@ function addBoundaryLayer(mapInstance, geojson) {
}
function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
- if (
- !edpBoundaryGeojson ||
- !edpBoundaryGeojson.features ||
- !edpBoundaryGeojson.features.length
- ) {
+ if (!edpBoundaryGeojson?.features?.length) {
return
}
- if (mapInstance.getSource('edp-boundary')) {
+ if (mapInstance.getSource(SOURCE_EDP_BOUNDARY)) {
return
}
- mapInstance.addSource('edp-boundary', {
+ mapInstance.addSource(SOURCE_EDP_BOUNDARY, {
type: 'geojson',
data: edpBoundaryGeojson
})
@@ -116,7 +116,7 @@ function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
mapInstance.addLayer({
id: 'edp-boundary-fill',
type: 'fill',
- source: 'edp-boundary',
+ source: SOURCE_EDP_BOUNDARY,
paint: {
'fill-color': '#00703c',
'fill-opacity': 0.08
@@ -126,7 +126,7 @@ function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
mapInstance.addLayer({
id: 'edp-boundary-line',
type: 'line',
- source: 'edp-boundary',
+ source: SOURCE_EDP_BOUNDARY,
paint: {
'line-color': '#00703c',
'line-width': 2
@@ -135,15 +135,15 @@ function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
}
function addEdpIntersectionLayer(mapInstance, edpGeojson) {
- if (!edpGeojson || !edpGeojson.features || !edpGeojson.features.length) {
+ if (!edpGeojson?.features?.length) {
return
}
- if (mapInstance.getSource('edp-intersection')) {
+ if (mapInstance.getSource(SOURCE_EDP_INTERSECTION)) {
return
}
- mapInstance.addSource('edp-intersection', {
+ mapInstance.addSource(SOURCE_EDP_INTERSECTION, {
type: 'geojson',
data: edpGeojson
})
@@ -151,7 +151,7 @@ function addEdpIntersectionLayer(mapInstance, edpGeojson) {
mapInstance.addLayer({
id: 'edp-intersection-fill',
type: 'fill',
- source: 'edp-intersection',
+ source: SOURCE_EDP_INTERSECTION,
paint: {
'fill-color': '#1d70b8',
'fill-opacity': 0.3
@@ -161,7 +161,7 @@ function addEdpIntersectionLayer(mapInstance, edpGeojson) {
mapInstance.addLayer({
id: 'edp-intersection-line',
type: 'line',
- source: 'edp-intersection',
+ source: SOURCE_EDP_INTERSECTION,
paint: {
'line-color': '#1d70b8',
'line-width': 2,
From 4eb712e0b175758da71f5361e071b4c31d3568e4 Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Tue, 17 Mar 2026 17:15:52 +0000
Subject: [PATCH 05/10] Fix SonarCloud issues in boundary-map.js
- Use optional chaining in EDP layer guard clauses
- Extract source ID constants to avoid duplicated literals
- Add console.warn in EDP GeoJSON parse catch blocks
Co-Authored-By: Claude Opus 4.6
---
src/client/javascripts/boundary-map.js | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index 3ddf28f..90f2b0d 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -173,7 +173,8 @@ function addEdpIntersectionLayer(mapInstance, edpGeojson) {
function parseEdpBoundaryGeojson(mapEl) {
try {
return JSON.parse(mapEl.dataset.edpBoundaryGeojson)
- } catch (e) {
+ } catch {
+ console.warn('Failed to parse EDP boundary GeoJSON')
return null
}
}
@@ -181,7 +182,8 @@ function parseEdpBoundaryGeojson(mapEl) {
function parseEdpIntersectionGeojson(mapEl) {
try {
return JSON.parse(mapEl.dataset.edpIntersectionGeojson)
- } catch (e) {
+ } catch {
+ console.warn('Failed to parse EDP intersection GeoJSON')
return null
}
}
From 4588a6dd233911c1f5bd2d2d453dfbec5017a079 Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Tue, 17 Mar 2026 17:22:42 +0000
Subject: [PATCH 06/10] const
---
src/client/javascripts/boundary-map.js | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index 90f2b0d..d217303 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -8,9 +8,9 @@
/* global defra */
-var SOURCE_BOUNDARY = 'boundary'
-var SOURCE_EDP_BOUNDARY = 'edp-boundary'
-var SOURCE_EDP_INTERSECTION = 'edp-intersection'
+const SOURCE_BOUNDARY = 'boundary'
+const SOURCE_EDP_BOUNDARY = 'edp-boundary'
+const SOURCE_EDP_INTERSECTION = 'edp-intersection'
function parseGeojson(mapEl) {
try {
From 5f9382ba4a458bc7a4c1a706377404a4ce9827eb Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Thu, 19 Mar 2026 13:45:55 +0000
Subject: [PATCH 07/10] Map source constants
---
src/client/javascripts/boundary-map.js | 31 +++++++++++++-------------
1 file changed, 16 insertions(+), 15 deletions(-)
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index d217303..20d59fe 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -8,9 +8,10 @@
/* global defra */
-const SOURCE_BOUNDARY = 'boundary'
-const SOURCE_EDP_BOUNDARY = 'edp-boundary'
-const SOURCE_EDP_INTERSECTION = 'edp-intersection'
+// MapLibre source IDs – each source holds GeoJSON data rendered by one or more layers
+const MAP_SOURCE_BOUNDARY = 'boundary'
+const MAP_SOURCE_EDP_BOUNDARY = 'edp-boundary'
+const MAP_SOURCE_EDP_INTERSECTION = 'edp-intersection'
function parseGeojson(mapEl) {
try {
@@ -67,11 +68,11 @@ function fitMapToBounds(mapInstance, geojson) {
}
function addBoundaryLayer(mapInstance, geojson) {
- if (mapInstance.getSource(SOURCE_BOUNDARY)) {
+ if (mapInstance.getSource(MAP_SOURCE_BOUNDARY)) {
return
}
- mapInstance.addSource(SOURCE_BOUNDARY, {
+ mapInstance.addSource(MAP_SOURCE_BOUNDARY, {
type: 'geojson',
data: geojson
})
@@ -79,7 +80,7 @@ function addBoundaryLayer(mapInstance, geojson) {
mapInstance.addLayer({
id: 'boundary-fill',
type: 'fill',
- source: SOURCE_BOUNDARY,
+ source: MAP_SOURCE_BOUNDARY,
paint: {
'fill-color': '#d4351c',
'fill-opacity': 0.1
@@ -89,7 +90,7 @@ function addBoundaryLayer(mapInstance, geojson) {
mapInstance.addLayer({
id: 'boundary-line',
type: 'line',
- source: SOURCE_BOUNDARY,
+ source: MAP_SOURCE_BOUNDARY,
paint: {
'line-color': '#d4351c',
'line-width': 3
@@ -104,11 +105,11 @@ function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
return
}
- if (mapInstance.getSource(SOURCE_EDP_BOUNDARY)) {
+ if (mapInstance.getSource(MAP_SOURCE_EDP_BOUNDARY)) {
return
}
- mapInstance.addSource(SOURCE_EDP_BOUNDARY, {
+ mapInstance.addSource(MAP_SOURCE_EDP_BOUNDARY, {
type: 'geojson',
data: edpBoundaryGeojson
})
@@ -116,7 +117,7 @@ function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
mapInstance.addLayer({
id: 'edp-boundary-fill',
type: 'fill',
- source: SOURCE_EDP_BOUNDARY,
+ source: MAP_SOURCE_EDP_BOUNDARY,
paint: {
'fill-color': '#00703c',
'fill-opacity': 0.08
@@ -126,7 +127,7 @@ function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
mapInstance.addLayer({
id: 'edp-boundary-line',
type: 'line',
- source: SOURCE_EDP_BOUNDARY,
+ source: MAP_SOURCE_EDP_BOUNDARY,
paint: {
'line-color': '#00703c',
'line-width': 2
@@ -139,11 +140,11 @@ function addEdpIntersectionLayer(mapInstance, edpGeojson) {
return
}
- if (mapInstance.getSource(SOURCE_EDP_INTERSECTION)) {
+ if (mapInstance.getSource(MAP_SOURCE_EDP_INTERSECTION)) {
return
}
- mapInstance.addSource(SOURCE_EDP_INTERSECTION, {
+ mapInstance.addSource(MAP_SOURCE_EDP_INTERSECTION, {
type: 'geojson',
data: edpGeojson
})
@@ -151,7 +152,7 @@ function addEdpIntersectionLayer(mapInstance, edpGeojson) {
mapInstance.addLayer({
id: 'edp-intersection-fill',
type: 'fill',
- source: SOURCE_EDP_INTERSECTION,
+ source: MAP_SOURCE_EDP_INTERSECTION,
paint: {
'fill-color': '#1d70b8',
'fill-opacity': 0.3
@@ -161,7 +162,7 @@ function addEdpIntersectionLayer(mapInstance, edpGeojson) {
mapInstance.addLayer({
id: 'edp-intersection-line',
type: 'line',
- source: SOURCE_EDP_INTERSECTION,
+ source: MAP_SOURCE_EDP_INTERSECTION,
paint: {
'line-color': '#1d70b8',
'line-width': 2,
From f72888bb8fffc05f5eb862cb3f648aad35b2ca15 Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Thu, 19 Mar 2026 13:52:00 +0000
Subject: [PATCH 08/10] Split files
---
.../__fixtures__/boundary-map-fixtures.js | 101 +++++++
src/client/javascripts/boundary-map-geo.js | 48 +++
.../javascripts/boundary-map-geo.test.js | 82 ++++++
src/client/javascripts/boundary-map-layers.js | 114 ++++++++
.../javascripts/boundary-map-layers.test.js | 139 +++++++++
src/client/javascripts/boundary-map.js | 158 +---------
src/client/javascripts/boundary-map.test.js | 275 +-----------------
7 files changed, 502 insertions(+), 415 deletions(-)
create mode 100644 src/client/javascripts/__fixtures__/boundary-map-fixtures.js
create mode 100644 src/client/javascripts/boundary-map-geo.js
create mode 100644 src/client/javascripts/boundary-map-geo.test.js
create mode 100644 src/client/javascripts/boundary-map-layers.js
create mode 100644 src/client/javascripts/boundary-map-layers.test.js
diff --git a/src/client/javascripts/__fixtures__/boundary-map-fixtures.js b/src/client/javascripts/__fixtures__/boundary-map-fixtures.js
new file mode 100644
index 0000000..f00e9c0
--- /dev/null
+++ b/src/client/javascripts/__fixtures__/boundary-map-fixtures.js
@@ -0,0 +1,101 @@
+export const validGeojson = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.5, 52.0],
+ [-1.4, 52.0],
+ [-1.4, 52.1],
+ [-1.5, 52.1],
+ [-1.5, 52.0]
+ ]
+ ]
+ },
+ properties: {}
+ }
+ ]
+}
+
+export const validEdpBoundaryGeojson = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.6, 51.9],
+ [-1.3, 51.9],
+ [-1.3, 52.2],
+ [-1.6, 52.2],
+ [-1.6, 51.9]
+ ]
+ ]
+ },
+ properties: { label: 'EDP 1' }
+ }
+ ]
+}
+
+export const validEdpIntersectionGeojson = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-1.5, 52.0],
+ [-1.45, 52.0],
+ [-1.45, 52.05],
+ [-1.5, 52.05],
+ [-1.5, 52.0]
+ ]
+ ]
+ },
+ properties: {
+ label: 'EDP 1',
+ overlap_area_ha: 0.5,
+ overlap_percentage: 25.0
+ }
+ }
+ ]
+}
+
+export const singleGeometry = {
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: [-1.5, 52.0] },
+ properties: {}
+}
+
+export const multiPolygon = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'MultiPolygon',
+ coordinates: [
+ [
+ [
+ [-2.0, 51.0],
+ [-1.0, 51.0],
+ [-1.0, 53.0],
+ [-2.0, 53.0],
+ [-2.0, 51.0]
+ ]
+ ]
+ ]
+ },
+ properties: {}
+ }
+ ]
+}
+
+export const emptyGeojson = { type: 'FeatureCollection', features: [] }
diff --git a/src/client/javascripts/boundary-map-geo.js b/src/client/javascripts/boundary-map-geo.js
new file mode 100644
index 0000000..6bc1128
--- /dev/null
+++ b/src/client/javascripts/boundary-map-geo.js
@@ -0,0 +1,48 @@
+/**
+ * Geometry helpers for the boundary map.
+ */
+
+export function collectCoords(c, coords) {
+ if (typeof c[0] === 'number') {
+ coords.push(c)
+ } else {
+ c.forEach(function (inner) {
+ collectCoords(inner, coords)
+ })
+ }
+}
+
+export function fitMapToBounds(mapInstance, geojson) {
+ const coords = []
+ ;(geojson.features || [geojson]).forEach(function (f) {
+ collectCoords(f.geometry ? f.geometry.coordinates : f.coordinates, coords)
+ })
+
+ if (coords.length) {
+ let west = coords[0][0]
+ let south = coords[0][1]
+ let east = coords[0][0]
+ let north = coords[0][1]
+ coords.forEach(function (c) {
+ if (c[0] < west) {
+ west = c[0]
+ }
+ if (c[0] > east) {
+ east = c[0]
+ }
+ if (c[1] < south) {
+ south = c[1]
+ }
+ if (c[1] > north) {
+ north = c[1]
+ }
+ })
+ mapInstance.fitBounds(
+ [
+ [west, south],
+ [east, north]
+ ],
+ { padding: 40 }
+ )
+ }
+}
diff --git a/src/client/javascripts/boundary-map-geo.test.js b/src/client/javascripts/boundary-map-geo.test.js
new file mode 100644
index 0000000..26c8f31
--- /dev/null
+++ b/src/client/javascripts/boundary-map-geo.test.js
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest'
+import { collectCoords, fitMapToBounds } from './boundary-map-geo.js'
+import {
+ validGeojson,
+ singleGeometry,
+ multiPolygon,
+ emptyGeojson
+} from './__fixtures__/boundary-map-fixtures.js'
+
+describe('collectCoords', () => {
+ it('collects flat coordinate pairs', () => {
+ const coords = []
+ collectCoords([-1.5, 52.0], coords)
+ expect(coords).toEqual([[-1.5, 52.0]])
+ })
+
+ it('collects nested coordinate arrays', () => {
+ const coords = []
+ collectCoords(
+ [
+ [
+ [-2.0, 51.0],
+ [-1.0, 53.0]
+ ]
+ ],
+ coords
+ )
+ expect(coords).toEqual([
+ [-2.0, 51.0],
+ [-1.0, 53.0]
+ ])
+ })
+})
+
+describe('fitMapToBounds', () => {
+ it('calculates bounds from a FeatureCollection', () => {
+ const mapInstance = { fitBounds: vi.fn() }
+ fitMapToBounds(mapInstance, validGeojson)
+
+ expect(mapInstance.fitBounds).toHaveBeenCalledWith(
+ [
+ [-1.5, 52.0],
+ [-1.4, 52.1]
+ ],
+ { padding: 40 }
+ )
+ })
+
+ it('handles a single geometry (no features array)', () => {
+ const mapInstance = { fitBounds: vi.fn() }
+ fitMapToBounds(mapInstance, singleGeometry)
+
+ expect(mapInstance.fitBounds).toHaveBeenCalledWith(
+ [
+ [-1.5, 52.0],
+ [-1.5, 52.0]
+ ],
+ { padding: 40 }
+ )
+ })
+
+ it('handles nested coordinate arrays (MultiPolygon)', () => {
+ const mapInstance = { fitBounds: vi.fn() }
+ fitMapToBounds(mapInstance, multiPolygon)
+
+ expect(mapInstance.fitBounds).toHaveBeenCalledWith(
+ [
+ [-2.0, 51.0],
+ [-1.0, 53.0]
+ ],
+ { padding: 40 }
+ )
+ })
+
+ it('does not call fitBounds for empty features', () => {
+ const mapInstance = { fitBounds: vi.fn() }
+ fitMapToBounds(mapInstance, emptyGeojson)
+
+ expect(mapInstance.fitBounds).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/client/javascripts/boundary-map-layers.js b/src/client/javascripts/boundary-map-layers.js
new file mode 100644
index 0000000..cef46e2
--- /dev/null
+++ b/src/client/javascripts/boundary-map-layers.js
@@ -0,0 +1,114 @@
+/**
+ * Map layer functions for the boundary map.
+ */
+
+import { fitMapToBounds } from './boundary-map-geo.js'
+
+// MapLibre source IDs – each source holds GeoJSON data rendered by one or more layers
+const MAP_SOURCE_BOUNDARY = 'boundary'
+const MAP_SOURCE_EDP_BOUNDARY = 'edp-boundary'
+const MAP_SOURCE_EDP_INTERSECTION = 'edp-intersection'
+
+export function addBoundaryLayer(mapInstance, geojson) {
+ if (mapInstance.getSource(MAP_SOURCE_BOUNDARY)) {
+ return
+ }
+
+ mapInstance.addSource(MAP_SOURCE_BOUNDARY, {
+ type: 'geojson',
+ data: geojson
+ })
+
+ mapInstance.addLayer({
+ id: 'boundary-fill',
+ type: 'fill',
+ source: MAP_SOURCE_BOUNDARY,
+ paint: {
+ 'fill-color': '#d4351c',
+ 'fill-opacity': 0.1
+ }
+ })
+
+ mapInstance.addLayer({
+ id: 'boundary-line',
+ type: 'line',
+ source: MAP_SOURCE_BOUNDARY,
+ paint: {
+ 'line-color': '#d4351c',
+ 'line-width': 3
+ }
+ })
+
+ fitMapToBounds(mapInstance, geojson)
+}
+
+export function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
+ if (!edpBoundaryGeojson?.features?.length) {
+ return
+ }
+
+ if (mapInstance.getSource(MAP_SOURCE_EDP_BOUNDARY)) {
+ return
+ }
+
+ mapInstance.addSource(MAP_SOURCE_EDP_BOUNDARY, {
+ type: 'geojson',
+ data: edpBoundaryGeojson
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-boundary-fill',
+ type: 'fill',
+ source: MAP_SOURCE_EDP_BOUNDARY,
+ paint: {
+ 'fill-color': '#00703c',
+ 'fill-opacity': 0.08
+ }
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-boundary-line',
+ type: 'line',
+ source: MAP_SOURCE_EDP_BOUNDARY,
+ paint: {
+ 'line-color': '#00703c',
+ 'line-width': 2
+ }
+ })
+}
+
+export function addEdpIntersectionLayer(mapInstance, edpGeojson) {
+ if (!edpGeojson?.features?.length) {
+ return
+ }
+
+ if (mapInstance.getSource(MAP_SOURCE_EDP_INTERSECTION)) {
+ return
+ }
+
+ mapInstance.addSource(MAP_SOURCE_EDP_INTERSECTION, {
+ type: 'geojson',
+ data: edpGeojson
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-intersection-fill',
+ type: 'fill',
+ source: MAP_SOURCE_EDP_INTERSECTION,
+ paint: {
+ 'fill-color': '#1d70b8',
+ 'fill-opacity': 0.3
+ }
+ })
+
+ mapInstance.addLayer({
+ id: 'edp-intersection-line',
+ type: 'line',
+ source: MAP_SOURCE_EDP_INTERSECTION,
+ paint: {
+ 'line-color': '#1d70b8',
+ 'line-width': 2,
+ 'line-dasharray': [4, 2]
+ }
+ })
+}
diff --git a/src/client/javascripts/boundary-map-layers.test.js b/src/client/javascripts/boundary-map-layers.test.js
new file mode 100644
index 0000000..48463b9
--- /dev/null
+++ b/src/client/javascripts/boundary-map-layers.test.js
@@ -0,0 +1,139 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest'
+import {
+ addBoundaryLayer,
+ addEdpBoundaryLayer,
+ addEdpIntersectionLayer
+} from './boundary-map-layers.js'
+import {
+ validGeojson,
+ validEdpBoundaryGeojson,
+ validEdpIntersectionGeojson,
+ emptyGeojson
+} from './__fixtures__/boundary-map-fixtures.js'
+
+function createMockMapInstance() {
+ return {
+ getSource: vi.fn().mockReturnValue(null),
+ addSource: vi.fn(),
+ addLayer: vi.fn(),
+ fitBounds: vi.fn()
+ }
+}
+
+describe('addBoundaryLayer', () => {
+ it('adds source and two layers', () => {
+ const mapInstance = createMockMapInstance()
+ addBoundaryLayer(mapInstance, validGeojson)
+
+ expect(mapInstance.addSource).toHaveBeenCalledWith('boundary', {
+ type: 'geojson',
+ data: validGeojson
+ })
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
+ expect(mapInstance.fitBounds).toHaveBeenCalled()
+ })
+
+ it('skips if source already exists', () => {
+ const mapInstance = createMockMapInstance()
+ mapInstance.getSource.mockReturnValue({})
+ addBoundaryLayer(mapInstance, validGeojson)
+
+ expect(mapInstance.addSource).not.toHaveBeenCalled()
+ expect(mapInstance.addLayer).not.toHaveBeenCalled()
+ })
+})
+
+describe('addEdpBoundaryLayer', () => {
+ it('adds source and two layers', () => {
+ const mapInstance = createMockMapInstance()
+ addEdpBoundaryLayer(mapInstance, validEdpBoundaryGeojson)
+
+ expect(mapInstance.addSource).toHaveBeenCalledWith('edp-boundary', {
+ type: 'geojson',
+ data: validEdpBoundaryGeojson
+ })
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-boundary-fill',
+ source: 'edp-boundary'
+ })
+ )
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-boundary-line',
+ source: 'edp-boundary'
+ })
+ )
+ })
+
+ it('skips when features array is empty', () => {
+ const mapInstance = createMockMapInstance()
+ addEdpBoundaryLayer(mapInstance, emptyGeojson)
+
+ expect(mapInstance.addSource).not.toHaveBeenCalled()
+ })
+
+ it('skips when geojson is null', () => {
+ const mapInstance = createMockMapInstance()
+ addEdpBoundaryLayer(mapInstance, null)
+
+ expect(mapInstance.addSource).not.toHaveBeenCalled()
+ })
+
+ it('skips if source already exists', () => {
+ const mapInstance = createMockMapInstance()
+ mapInstance.getSource.mockReturnValue({})
+ addEdpBoundaryLayer(mapInstance, validEdpBoundaryGeojson)
+
+ expect(mapInstance.addSource).not.toHaveBeenCalled()
+ })
+})
+
+describe('addEdpIntersectionLayer', () => {
+ it('adds source and two layers', () => {
+ const mapInstance = createMockMapInstance()
+ addEdpIntersectionLayer(mapInstance, validEdpIntersectionGeojson)
+
+ expect(mapInstance.addSource).toHaveBeenCalledWith('edp-intersection', {
+ type: 'geojson',
+ data: validEdpIntersectionGeojson
+ })
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-intersection-fill',
+ source: 'edp-intersection'
+ })
+ )
+ expect(mapInstance.addLayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'edp-intersection-line',
+ source: 'edp-intersection'
+ })
+ )
+ })
+
+ it('skips when features array is empty', () => {
+ const mapInstance = createMockMapInstance()
+ addEdpIntersectionLayer(mapInstance, emptyGeojson)
+
+ expect(mapInstance.addSource).not.toHaveBeenCalled()
+ })
+
+ it('skips when geojson is null', () => {
+ const mapInstance = createMockMapInstance()
+ addEdpIntersectionLayer(mapInstance, null)
+
+ expect(mapInstance.addSource).not.toHaveBeenCalled()
+ })
+
+ it('skips if source already exists', () => {
+ const mapInstance = createMockMapInstance()
+ mapInstance.getSource.mockReturnValue({})
+ addEdpIntersectionLayer(mapInstance, validEdpIntersectionGeojson)
+
+ expect(mapInstance.addSource).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js
index 20d59fe..49cc935 100644
--- a/src/client/javascripts/boundary-map.js
+++ b/src/client/javascripts/boundary-map.js
@@ -8,10 +8,11 @@
/* global defra */
-// MapLibre source IDs – each source holds GeoJSON data rendered by one or more layers
-const MAP_SOURCE_BOUNDARY = 'boundary'
-const MAP_SOURCE_EDP_BOUNDARY = 'edp-boundary'
-const MAP_SOURCE_EDP_INTERSECTION = 'edp-intersection'
+import {
+ addBoundaryLayer,
+ addEdpBoundaryLayer,
+ addEdpIntersectionLayer
+} from './boundary-map-layers.js'
function parseGeojson(mapEl) {
try {
@@ -22,155 +23,6 @@ function parseGeojson(mapEl) {
}
}
-function collectCoords(c, coords) {
- if (typeof c[0] === 'number') {
- coords.push(c)
- } else {
- c.forEach(function (inner) {
- collectCoords(inner, coords)
- })
- }
-}
-
-function fitMapToBounds(mapInstance, geojson) {
- const coords = []
- ;(geojson.features || [geojson]).forEach(function (f) {
- collectCoords(f.geometry ? f.geometry.coordinates : f.coordinates, coords)
- })
-
- if (coords.length) {
- let west = coords[0][0]
- let south = coords[0][1]
- let east = coords[0][0]
- let north = coords[0][1]
- coords.forEach(function (c) {
- if (c[0] < west) {
- west = c[0]
- }
- if (c[0] > east) {
- east = c[0]
- }
- if (c[1] < south) {
- south = c[1]
- }
- if (c[1] > north) {
- north = c[1]
- }
- })
- mapInstance.fitBounds(
- [
- [west, south],
- [east, north]
- ],
- { padding: 40 }
- )
- }
-}
-
-function addBoundaryLayer(mapInstance, geojson) {
- if (mapInstance.getSource(MAP_SOURCE_BOUNDARY)) {
- return
- }
-
- mapInstance.addSource(MAP_SOURCE_BOUNDARY, {
- type: 'geojson',
- data: geojson
- })
-
- mapInstance.addLayer({
- id: 'boundary-fill',
- type: 'fill',
- source: MAP_SOURCE_BOUNDARY,
- paint: {
- 'fill-color': '#d4351c',
- 'fill-opacity': 0.1
- }
- })
-
- mapInstance.addLayer({
- id: 'boundary-line',
- type: 'line',
- source: MAP_SOURCE_BOUNDARY,
- paint: {
- 'line-color': '#d4351c',
- 'line-width': 3
- }
- })
-
- fitMapToBounds(mapInstance, geojson)
-}
-
-function addEdpBoundaryLayer(mapInstance, edpBoundaryGeojson) {
- if (!edpBoundaryGeojson?.features?.length) {
- return
- }
-
- if (mapInstance.getSource(MAP_SOURCE_EDP_BOUNDARY)) {
- return
- }
-
- mapInstance.addSource(MAP_SOURCE_EDP_BOUNDARY, {
- type: 'geojson',
- data: edpBoundaryGeojson
- })
-
- mapInstance.addLayer({
- id: 'edp-boundary-fill',
- type: 'fill',
- source: MAP_SOURCE_EDP_BOUNDARY,
- paint: {
- 'fill-color': '#00703c',
- 'fill-opacity': 0.08
- }
- })
-
- mapInstance.addLayer({
- id: 'edp-boundary-line',
- type: 'line',
- source: MAP_SOURCE_EDP_BOUNDARY,
- paint: {
- 'line-color': '#00703c',
- 'line-width': 2
- }
- })
-}
-
-function addEdpIntersectionLayer(mapInstance, edpGeojson) {
- if (!edpGeojson?.features?.length) {
- return
- }
-
- if (mapInstance.getSource(MAP_SOURCE_EDP_INTERSECTION)) {
- return
- }
-
- mapInstance.addSource(MAP_SOURCE_EDP_INTERSECTION, {
- type: 'geojson',
- data: edpGeojson
- })
-
- mapInstance.addLayer({
- id: 'edp-intersection-fill',
- type: 'fill',
- source: MAP_SOURCE_EDP_INTERSECTION,
- paint: {
- 'fill-color': '#1d70b8',
- 'fill-opacity': 0.3
- }
- })
-
- mapInstance.addLayer({
- id: 'edp-intersection-line',
- type: 'line',
- source: MAP_SOURCE_EDP_INTERSECTION,
- paint: {
- 'line-color': '#1d70b8',
- 'line-width': 2,
- 'line-dasharray': [4, 2]
- }
- })
-}
-
function parseEdpBoundaryGeojson(mapEl) {
try {
return JSON.parse(mapEl.dataset.edpBoundaryGeojson)
diff --git a/src/client/javascripts/boundary-map.test.js b/src/client/javascripts/boundary-map.test.js
index fb4341d..007ffdf 100644
--- a/src/client/javascripts/boundary-map.test.js
+++ b/src/client/javascripts/boundary-map.test.js
@@ -1,75 +1,10 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-
-const validGeojson = {
- type: 'FeatureCollection',
- features: [
- {
- type: 'Feature',
- geometry: {
- type: 'Polygon',
- coordinates: [
- [
- [-1.5, 52.0],
- [-1.4, 52.0],
- [-1.4, 52.1],
- [-1.5, 52.1],
- [-1.5, 52.0]
- ]
- ]
- },
- properties: {}
- }
- ]
-}
-
-const validEdpBoundaryGeojson = {
- type: 'FeatureCollection',
- features: [
- {
- type: 'Feature',
- geometry: {
- type: 'Polygon',
- coordinates: [
- [
- [-1.6, 51.9],
- [-1.3, 51.9],
- [-1.3, 52.2],
- [-1.6, 52.2],
- [-1.6, 51.9]
- ]
- ]
- },
- properties: { label: 'EDP 1' }
- }
- ]
-}
-
-const validEdpIntersectionGeojson = {
- type: 'FeatureCollection',
- features: [
- {
- type: 'Feature',
- geometry: {
- type: 'Polygon',
- coordinates: [
- [
- [-1.5, 52.0],
- [-1.45, 52.0],
- [-1.45, 52.05],
- [-1.5, 52.05],
- [-1.5, 52.0]
- ]
- ]
- },
- properties: {
- label: 'EDP 1',
- overlap_area_ha: 0.5,
- overlap_percentage: 25.0
- }
- }
- ]
-}
+import {
+ validGeojson,
+ validEdpBoundaryGeojson,
+ validEdpIntersectionGeojson
+} from './__fixtures__/boundary-map-fixtures.js'
function createMapElement(
geojson,
@@ -112,7 +47,6 @@ function createMockMapInstance(styleLoaded = true) {
function createMockDefra(mapInstance) {
const mockMap = { on: vi.fn() }
- // Use a real function so it works with `new`
function MockInteractiveMap() {
return mockMap
}
@@ -147,7 +81,7 @@ function createMockDefra(mapInstance) {
let initFn = null
const originalAddEventListener = document.addEventListener.bind(document)
-describe('boundary-map', () => {
+describe('boundary-map init', () => {
let warnSpy
beforeEach(() => {
@@ -156,7 +90,6 @@ describe('boundary-map', () => {
vi.resetModules()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
- // Intercept addEventListener to capture the init function
vi.spyOn(document, 'addEventListener').mockImplementation(
(event, fn, ...rest) => {
if (event === 'DOMContentLoaded') {
@@ -175,7 +108,6 @@ describe('boundary-map', () => {
async function loadModule() {
await import('./boundary-map.js')
- // Call the captured init function directly
if (initFn) {
initFn()
}
@@ -246,8 +178,11 @@ describe('boundary-map', () => {
)
})
- it('adds boundary layers when style is already loaded', async () => {
- createMapElement(validGeojson)
+ it('adds all layers when style is already loaded', async () => {
+ createMapElement(validGeojson, 'https://example.com/style.json', {
+ edpBoundary: validEdpBoundaryGeojson,
+ edpIntersection: validEdpIntersectionGeojson
+ })
const mapInstance = createMockMapInstance(true)
const mockDefra = createMockDefra(mapInstance)
globalThis.defra = mockDefra
@@ -255,18 +190,8 @@ describe('boundary-map', () => {
await loadModule()
mockDefra._triggerReady()
- expect(mapInstance.addSource).toHaveBeenCalledWith('boundary', {
- type: 'geojson',
- data: validGeojson
- })
- expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
- expect(mapInstance.fitBounds).toHaveBeenCalledWith(
- [
- [-1.5, 52.0],
- [-1.4, 52.1]
- ],
- { padding: 40 }
- )
+ expect(mapInstance.addSource).toHaveBeenCalledTimes(3)
+ expect(mapInstance.addLayer).toHaveBeenCalledTimes(6)
})
it('waits for style.load when style is not yet loaded', async () => {
@@ -284,7 +209,6 @@ describe('boundary-map', () => {
expect.any(Function)
)
- // Trigger the style.load callback
const styleLoadCallback = mapInstance.once.mock.calls[0][1]
styleLoadCallback()
@@ -293,177 +217,4 @@ describe('boundary-map', () => {
expect.objectContaining({ type: 'geojson' })
)
})
-
- it('skips adding layers if boundary source already exists', async () => {
- createMapElement(validGeojson)
- const mapInstance = createMockMapInstance(true)
- mapInstance.getSource.mockReturnValue({})
- const mockDefra = createMockDefra(mapInstance)
- globalThis.defra = mockDefra
-
- await loadModule()
- mockDefra._triggerReady()
-
- expect(mapInstance.addSource).not.toHaveBeenCalled()
- expect(mapInstance.addLayer).not.toHaveBeenCalled()
- })
-
- it('handles geojson without features array (single geometry)', async () => {
- const singleGeometry = {
- type: 'Feature',
- geometry: { type: 'Point', coordinates: [-1.5, 52.0] },
- properties: {}
- }
- createMapElement(singleGeometry)
- const mapInstance = createMockMapInstance(true)
- const mockDefra = createMockDefra(mapInstance)
- globalThis.defra = mockDefra
-
- await loadModule()
- mockDefra._triggerReady()
-
- expect(mapInstance.fitBounds).toHaveBeenCalledWith(
- [
- [-1.5, 52.0],
- [-1.5, 52.0]
- ],
- { padding: 40 }
- )
- })
-
- it('handles nested coordinate arrays (MultiPolygon)', async () => {
- const multiPolygon = {
- type: 'FeatureCollection',
- features: [
- {
- type: 'Feature',
- geometry: {
- type: 'MultiPolygon',
- coordinates: [
- [
- [
- [-2.0, 51.0],
- [-1.0, 51.0],
- [-1.0, 53.0],
- [-2.0, 53.0],
- [-2.0, 51.0]
- ]
- ]
- ]
- },
- properties: {}
- }
- ]
- }
- createMapElement(multiPolygon)
- const mapInstance = createMockMapInstance(true)
- const mockDefra = createMockDefra(mapInstance)
- globalThis.defra = mockDefra
-
- await loadModule()
- mockDefra._triggerReady()
-
- expect(mapInstance.fitBounds).toHaveBeenCalledWith(
- [
- [-2.0, 51.0],
- [-1.0, 53.0]
- ],
- { padding: 40 }
- )
- })
-
- it('handles empty features array without calling fitBounds', async () => {
- const emptyGeojson = { type: 'FeatureCollection', features: [] }
- createMapElement(emptyGeojson)
- const mapInstance = createMockMapInstance(true)
- const mockDefra = createMockDefra(mapInstance)
- globalThis.defra = mockDefra
-
- await loadModule()
- mockDefra._triggerReady()
-
- expect(mapInstance.fitBounds).not.toHaveBeenCalled()
- })
-
- it('adds EDP boundary and intersection layers when geojson is provided', async () => {
- createMapElement(validGeojson, 'https://example.com/style.json', {
- edpBoundary: validEdpBoundaryGeojson,
- edpIntersection: validEdpIntersectionGeojson
- })
- const mapInstance = createMockMapInstance(true)
- const mockDefra = createMockDefra(mapInstance)
- globalThis.defra = mockDefra
-
- await loadModule()
- mockDefra._triggerReady()
-
- expect(mapInstance.addSource).toHaveBeenCalledWith('edp-boundary', {
- type: 'geojson',
- data: validEdpBoundaryGeojson
- })
- expect(mapInstance.addSource).toHaveBeenCalledWith('edp-intersection', {
- type: 'geojson',
- data: validEdpIntersectionGeojson
- })
- // boundary (2) + edp boundary (2) + edp intersection (2) = 6 layers
- expect(mapInstance.addLayer).toHaveBeenCalledTimes(6)
- expect(mapInstance.addLayer).toHaveBeenCalledWith(
- expect.objectContaining({
- id: 'edp-boundary-fill',
- source: 'edp-boundary'
- })
- )
- expect(mapInstance.addLayer).toHaveBeenCalledWith(
- expect.objectContaining({
- id: 'edp-boundary-line',
- source: 'edp-boundary'
- })
- )
- expect(mapInstance.addLayer).toHaveBeenCalledWith(
- expect.objectContaining({
- id: 'edp-intersection-fill',
- source: 'edp-intersection'
- })
- )
- expect(mapInstance.addLayer).toHaveBeenCalledWith(
- expect.objectContaining({
- id: 'edp-intersection-line',
- source: 'edp-intersection'
- })
- )
- })
-
- it('does not add EDP layers when no edp geojson is provided', async () => {
- createMapElement(validGeojson)
- const mapInstance = createMockMapInstance(true)
- const mockDefra = createMockDefra(mapInstance)
- globalThis.defra = mockDefra
-
- await loadModule()
- mockDefra._triggerReady()
-
- expect(mapInstance.addSource).toHaveBeenCalledTimes(1)
- expect(mapInstance.addSource).toHaveBeenCalledWith(
- 'boundary',
- expect.any(Object)
- )
- expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
- })
-
- it('does not add EDP layers when edp geojson has empty features', async () => {
- const emptyEdp = { type: 'FeatureCollection', features: [] }
- createMapElement(validGeojson, 'https://example.com/style.json', {
- edpBoundary: emptyEdp,
- edpIntersection: emptyEdp
- })
- const mapInstance = createMockMapInstance(true)
- const mockDefra = createMockDefra(mapInstance)
- globalThis.defra = mockDefra
-
- await loadModule()
- mockDefra._triggerReady()
-
- expect(mapInstance.addSource).toHaveBeenCalledTimes(1)
- expect(mapInstance.addLayer).toHaveBeenCalledTimes(2)
- })
})
From e980d7398227e9883cbadd7cbd552d2174d8e95d Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Thu, 19 Mar 2026 14:06:52 +0000
Subject: [PATCH 09/10] natura2000SiteName
---
src/server/quote/map/get-view-model.js | 7 ++++++-
src/server/quote/map/get-view-model.test.js | 4 +++-
src/server/quote/map/index.njk | 2 +-
3 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/src/server/quote/map/get-view-model.js b/src/server/quote/map/get-view-model.js
index 99c6286..6497b35 100644
--- a/src/server/quote/map/get-view-model.js
+++ b/src/server/quote/map/get-view-model.js
@@ -37,11 +37,16 @@ export default function getViewModel(boundaryGeojson) {
}))
})
+ const mappedEdps = intersectingEdps.map((edp) => ({
+ ...edp,
+ natura2000SiteName: edp.n2k_site_name // n2k = Natura 2000 (EU protected sites network)
+ }))
+
return {
pageTitle: getPageTitle(title),
pageHeading: title,
boundaryGeojson: JSON.stringify(geometry),
- intersectingEdps,
+ intersectingEdps: mappedEdps,
intersectsEdp,
edpBoundaryGeojson,
edpIntersectionGeojson,
diff --git a/src/server/quote/map/get-view-model.test.js b/src/server/quote/map/get-view-model.test.js
index 4974e7e..c24e6f4 100644
--- a/src/server/quote/map/get-view-model.test.js
+++ b/src/server/quote/map/get-view-model.test.js
@@ -50,7 +50,9 @@ describe('getViewModel', () => {
const result = getViewModel(response)
expect(result.intersectsEdp).toBe(true)
- expect(result.intersectingEdps).toEqual(edps)
+ expect(result.intersectingEdps).toEqual(
+ edps.map((edp) => ({ ...edp, natura2000SiteName: edp.n2k_site_name }))
+ )
})
it('should handle missing fields gracefully', () => {
diff --git a/src/server/quote/map/index.njk b/src/server/quote/map/index.njk
index d5c469b..434b207 100644
--- a/src/server/quote/map/index.njk
+++ b/src/server/quote/map/index.njk
@@ -44,7 +44,7 @@
{% for edp in intersectingEdps %}
-
- {{ edp.label }}{% if edp.n2k_site_name %} ({{ edp.n2k_site_name }}){% endif %}
+ {{ edp.label }}{% if edp.natura2000SiteName %} ({{ edp.natura2000SiteName }}){% endif %}
{% if edp.overlap_area_ha %}
Overlap: {{ edp.overlap_area_ha }} ha ({{ edp.overlap_percentage }}% of boundary)
From 352172ac71d1e284767b841975f6c419294382fb Mon Sep 17 00:00:00 2001
From: robin-dunn <58361313+robin-dunn@users.noreply.github.com>
Date: Thu, 19 Mar 2026 14:56:32 +0000
Subject: [PATCH 10/10] sonarqube ignore geojson fixture
---
sonar-project.properties | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/sonar-project.properties b/sonar-project.properties
index a4f3fc6..9140163 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -13,7 +13,7 @@ sonar.links.scm=https://github.com/DEFRA/nrf-frontend
sonar.links.issue=https://github.com/DEFRA/nrf-frontend/issues
sonar.sources=src/
-sonar.exclusions=src/**/*.test.js,src/test-utils/**
+sonar.exclusions=src/**/*.test.js,src/test-utils/**,src/**/__fixtures__/**
sonar.tests=src/
sonar.test.inclusions=src/**/*.test.js