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 @@

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 @@