From d2e72f8d0dff1700322be69e0a47a36140abdbcc Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:53:49 +0400 Subject: [PATCH 01/21] Place v2's constituency hex maps from the app's layout, as v3 does v2's absolute and relative constituency hex maps drew each constituency at the x and y its result gives, on the untiled square grid. Those positions are the Open Innovations layout's [q, r], but the untiled grid shifts the even rows where the layout's odd-r rows need the odd ones shifted, so v2's arrangement differed from v3's in every other row. Both now place their areas with placeOnUKHexLayout, which holds the rule v3's local-area map had inline: each area whose ONS code the layout has takes the layout's position, on the tiled grid. An area the layout cannot place (no code, as in an older saved result, or a code the layout lacks) keeps its result's position, and the map then draws the untiled grid those positions were made for. v3's map now calls the same function, with no change in what it draws. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 49adbc21f45c591836f65c49cbc7d63bfe48bc14) --- .../flagship/report/UKLocalAreaMaps.tsx | 24 ++--- .../visualization/choropleth/ukHexLayout.ts | 32 ++++++ .../AbsoluteChangeByConstituency.tsx | 6 +- .../RelativeChangeByConstituency.tsx | 6 +- .../choropleth/ukHexLayoutMocks.ts | 31 ++++++ .../report-output/ukHexMapPlacementMocks.tsx | 98 +++++++++++++++++++ .../choropleth/ukHexLayout.test.ts | 59 ++++++++++- .../AbsoluteChangeByConstituency.test.tsx | 53 ++++++++++ .../RelativeChangeByConstituency.test.tsx | 57 ++++++++++- 9 files changed, 347 insertions(+), 19 deletions(-) create mode 100644 app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx diff --git a/app/src/components/flagship/report/UKLocalAreaMaps.tsx b/app/src/components/flagship/report/UKLocalAreaMaps.tsx index 3499ee442..ff6990418 100644 --- a/app/src/components/flagship/report/UKLocalAreaMaps.tsx +++ b/app/src/components/flagship/report/UKLocalAreaMaps.tsx @@ -38,7 +38,7 @@ import { ukNationFromRegion, } from '@/components/visualization/choropleth/ukAreaBoundaries'; import { UKAreaChoroplethMap } from '@/components/visualization/choropleth/UKAreaChoroplethMap'; -import { ukHexPosition } from '@/components/visualization/choropleth/ukHexLayout'; +import { placeOnUKHexLayout } from '@/components/visualization/choropleth/ukHexLayout'; import { HexagonalMap } from '@/components/visualization/HexagonalMap'; import { colors, spacing, typography } from '@/designTokens'; import type { ReportOutputSocietyWideByConstituency } from '@/types/metadata/ReportOutputSocietyWideByConstituency'; @@ -251,28 +251,22 @@ export default function UKLocalAreaMaps({ mode === 'absolute' ? 'average_household_income_change' : 'relative_household_income_change'; - // The hex map places an area by its code in the app's layout, and - // falls back to the API's position for a result without codes. - const position = ukHexPosition(level, area.code) ?? { x: area.x, y: area.y }; return { id: name, label: name, value: area[field], - x: position.x, - y: position.y, + x: area.x, + y: area.y, ...(area.code && { code: area.code }), ...areaEstimateFields(area, field), }; }), - [areas, level, mode] + [areas, mode] ); - // The layout's positions tile as an odd-r grid; the API's fallback - // positions keep the square grid they were made for. - const hexLayoutOwned = useMemo( - () => Object.values(areas ?? {}).every((area) => ukHexPosition(level, area.code) !== null), - [areas, level] - ); + // The hex map places each area by its code in the app's layout, tiled. + // A result without codes keeps the API's positions on their square grid. + const hexMap = useMemo(() => placeOnUKHexLayout(level, mapData), [level, mapData]); const decimals = useMemo(() => percentDecimals(mapData.map((point) => point.value)), [mapData]); const formatValue = useMemo( @@ -405,12 +399,12 @@ export default function UKLocalAreaMaps({ /> ) : ( { + const position = ukHexPosition(level, point.code); + if (!position) { + tiled = false; + return point; + } + return { ...point, ...position }; + }); + return { points: placed, tiled }; +} diff --git a/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx b/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx index 04184b94f..fee987edb 100644 --- a/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx +++ b/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx @@ -11,6 +11,7 @@ import { ukNationFromRegion, type MapVisualizationType, } from '@/components/visualization/choropleth'; +import { placeOnUKHexLayout } from '@/components/visualization/choropleth/ukHexLayout'; import { HexagonalMap } from '@/components/visualization/HexagonalMap'; import type { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK'; import { formatParameterValue } from '@/utils/chartValueUtils'; @@ -43,6 +44,8 @@ export function AbsoluteChangeByConstituency({ const areas = (output as ReportOutputSocietyWideUK).constituency_impact?.by_constituency; // One point per area, for the geographic and hex maps alike const mapData = useMemo(() => (areas ? transformConstituencyAbsoluteChange(areas) : []), [areas]); + // The hex map places each constituency by its code in the app's layout, as v3's does. + const hexMap = useMemo(() => placeOnUKHexLayout('constituency', mapData), [mapData]); const notes = ; if (!mapData.length) { @@ -89,8 +92,9 @@ export function AbsoluteChangeByConstituency({ /> ) : ( (areas ? transformConstituencyRelativeChange(areas) : []), [areas]); + // The hex map places each constituency by its code in the app's layout, as v3's does. + const hexMap = useMemo(() => placeOnUKHexLayout('constituency', mapData), [mapData]); const notes = ; if (!mapData.length) { @@ -89,8 +92,9 @@ export function RelativeChangeByConstituency({ /> ) : ( pair.split(',').map(Number) as [number, number]); +} + +/** A hexagon polygon's centre: the mean of its vertices. */ +function hexagonCentre(polygon: Element): HexagonCentre { + const vertices = hexagonVertices(polygon); + return { + x: vertices.reduce((sum, [vertexX]) => sum + vertexX, 0) / vertices.length, + y: vertices.reduce((sum, [, vertexY]) => sum + vertexY, 0) / vertices.length, + }; +} + +/** + * Whether a hexagon is pointy-topped, as on the tiled grid: its top and + * bottom vertices share an x. A flat-topped one's left and right share a y. + */ +export function isPointyTopped(polygon: Element): boolean { + const vertices = hexagonVertices(polygon); + return Math.abs(vertices[1][0] - vertices[4][0]) < 1e-6; +} + +/** Rounded to six places, and never -0, so arrangements compare with toEqual. */ +function roundCoordinate(value: number): number { + return Math.round(value * 1e6) / 1e6 + 0; +} + +/** + * The hexagons' centres moved so the first sits at the origin and scaled so + * the first two sit one unit apart. Two maps that put every area in the + * same hex give the same arrangement, whatever size they draw at. + */ +export function hexagonArrangement(polygons: Element[]): HexagonCentre[] { + const centres = polygons.map(hexagonCentre); + const [origin, second] = centres; + const unit = Math.hypot(second.x - origin.x, second.y - origin.y); + return centres.map((centre) => ({ + x: roundCoordinate((centre.x - origin.x) / unit), + y: roundCoordinate((centre.y - origin.y) / unit), + })); +} + +/** A level's areas in a result, by name, in the result's order. */ +export function areaNames(output: SocietyWideReportOutput, level: UKAreaLevel): string[] { + const uk = output as ReportOutputSocietyWideUK; + const areas = + level === 'constituency' + ? uk.constituency_impact?.by_constituency + : uk.local_authority_impact?.by_local_authority; + return Object.keys(areas ?? {}); +} + +/** + * The hexagons of v3's hex map of a level's areas, in the result's order: + * the arrangement v2's hex map must match. Renders v3's local-area map, + * reads it and unmounts it. + */ +export async function v3HexagonArrangement( + output: SocietyWideReportOutput, + level: UKAreaLevel +): Promise { + const user = userEvent.setup(); + const { container, unmount } = render(, 'uk'); + const v3Map = within(container); + if (level === 'local_authority') { + await user.click(v3Map.getByRole('tab', { name: 'Local authorities' })); + } + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(v3Map.getByRole('tab', { name: 'Hex grid' })); + const hexagons = areaNames(output, level).map( + (name) => container.querySelector(`[data-area-id="${name}"]`)! + ); + const arrangement = hexagonArrangement(hexagons); + unmount(); + return arrangement; +} + +/** v2's hexagons: its hex map is not selectable, so they carry no ids, and draw in the result's order. */ +export function v2Hexagons(container: HTMLElement): Element[] { + return [...container.querySelectorAll('svg polygon')]; +} diff --git a/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts index fe7d2f903..2050b4620 100644 --- a/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts +++ b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts @@ -1,10 +1,17 @@ import { describe, expect, test } from 'vitest'; -import { UK_HEX_LAYOUTS, ukHexPosition } from '@/components/visualization/choropleth/ukHexLayout'; +import { + placeOnUKHexLayout, + UK_HEX_LAYOUTS, + ukHexPosition, +} from '@/components/visualization/choropleth/ukHexLayout'; import { BARNSLEY_NORTH_CODE, BARNSLEY_NORTH_HEX, + BARNSLEY_NORTH_POINT, HARTLEPOOL_CODE, HARTLEPOOL_HEX, + HARTLEPOOL_POINT, + UNCODED_POINT, } from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; describe('ukHexPosition', () => { @@ -65,3 +72,53 @@ describe('ukHexPosition', () => { expect(Math.max(...scottishRows)).toBeGreaterThan(Math.max(...englishRows)); }); }); + +describe('placeOnUKHexLayout', () => { + test('given points whose codes the layout has then each takes its layout position, tiled', () => { + // When + const placement = placeOnUKHexLayout('constituency', [BARNSLEY_NORTH_POINT]); + + // Then + expect(placement).toEqual({ + points: [{ ...BARNSLEY_NORTH_POINT, x: BARNSLEY_NORTH_HEX[0], y: BARNSLEY_NORTH_HEX[1] + 1 }], + tiled: true, + }); + }); + + test('given a local authority then it reads the local authority layout', () => { + // When + const placement = placeOnUKHexLayout('local_authority', [HARTLEPOOL_POINT]); + + // Then + expect(placement.points[0]).toEqual({ + ...HARTLEPOOL_POINT, + x: HARTLEPOOL_HEX[0], + y: HARTLEPOOL_HEX[1] + 1, + }); + }); + + test('given a point without a code then it keeps its own position and the grid is not tiled', () => { + // When + const placement = placeOnUKHexLayout('constituency', [BARNSLEY_NORTH_POINT, UNCODED_POINT]); + + // Then + expect(placement.points[1]).toEqual(UNCODED_POINT); + expect(placement.tiled).toBe(false); + }); + + test('given a point without a layout position then the others still take theirs', () => { + // When + const placement = placeOnUKHexLayout('constituency', [BARNSLEY_NORTH_POINT, UNCODED_POINT]); + + // Then + expect(placement.points[0]).toMatchObject(ukHexPosition('constituency', BARNSLEY_NORTH_CODE)!); + }); + + test("given a code the level's layout lacks then the point keeps its own position", () => { + // When + const placement = placeOnUKHexLayout('constituency', [HARTLEPOOL_POINT]); + + // Then + expect(placement).toEqual({ points: [HARTLEPOOL_POINT], tiled: false }); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx index 8bf7c54b8..ad50d0e0e 100644 --- a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx @@ -1,8 +1,10 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { AbsoluteChangeByConstituency } from '@/pages/report-output/constituency/AbsoluteChangeByConstituency'; import { UK_LOCAL_AREAS_OUTPUT, + UK_LOCAL_AREAS_OUTPUT_NO_CODES, WALES_REGION, } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; import { NATIONAL_OUTPUT_WITHOUT_AREAS } from '@/tests/fixtures/components/flagship/report/ukLocalAreaSectionMocks'; @@ -19,6 +21,12 @@ import { MOCK_UK_REPORT_OUTPUT, MOCK_UK_REPORT_OUTPUT_NO_CONSTITUENCY, } from '@/tests/fixtures/pages/constituency/constituencyComponentMocks'; +import { + hexagonArrangement, + isPointyTopped, + v2Hexagons, + v3HexagonArrangement, +} from '@/tests/fixtures/pages/report-output/ukHexMapPlacementMocks'; import { withNullAreaImpacts } from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; beforeEach(() => { @@ -172,3 +180,48 @@ describe('AbsoluteChangeByConstituency map type', () => { expect(screen.getByRole('tooltip')).toHaveTextContent(`Cardiff East${NOT_PUBLISHED_TEXT}`); }); }); + +describe('AbsoluteChangeByConstituency hex map placement', () => { + /** The chart's hex map of `output`, with the hex grid chosen. */ + async function renderHexMap(output: SocietyWideReportOutput) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return container; + } + + test('given constituencies with codes then the hex map puts each in the hex v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement(UK_LOCAL_AREAS_OUTPUT, 'constituency'); + + // When + const container = await renderHexMap(UK_LOCAL_AREAS_OUTPUT); + + // Then + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + }); + + test('given constituencies with codes then the hex map draws the tiled grid’s pointy-topped hexagons', async () => { + // When + const container = await renderHexMap(UK_LOCAL_AREAS_OUTPUT); + + // Then + expect(v2Hexagons(container).every(isPointyTopped)).toBe(true); + }); + + test('given constituencies without codes then the hex map keeps their results’ positions, as v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement( + UK_LOCAL_AREAS_OUTPUT_NO_CODES, + 'constituency' + ); + + // When + const container = await renderHexMap(UK_LOCAL_AREAS_OUTPUT_NO_CODES); + + // Then: on the square grid of flat-topped hexagons those positions were made for + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx index 1e6742ae7..a6e1540e6 100644 --- a/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx @@ -1,7 +1,11 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { RelativeChangeByConstituency } from '@/pages/report-output/constituency/RelativeChangeByConstituency'; -import { UK_LOCAL_AREAS_OUTPUT } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; +import { + UK_LOCAL_AREAS_OUTPUT, + UK_LOCAL_AREAS_OUTPUT_NO_CODES, +} from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; import { NATIONAL_OUTPUT_WITHOUT_AREAS } from '@/tests/fixtures/components/flagship/report/ukLocalAreaSectionMocks'; import { GOWER_CODE, @@ -12,6 +16,12 @@ import { MOCK_UK_REPORT_OUTPUT, MOCK_UK_REPORT_OUTPUT_NO_CONSTITUENCY, } from '@/tests/fixtures/pages/constituency/constituencyComponentMocks'; +import { + hexagonArrangement, + isPointyTopped, + v2Hexagons, + v3HexagonArrangement, +} from '@/tests/fixtures/pages/report-output/ukHexMapPlacementMocks'; import { withNullAreaImpacts } from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; beforeEach(() => { @@ -111,3 +121,48 @@ describe('RelativeChangeByConstituency map type', () => { expect(screen.queryByTestId(`area-${GOWER_CODE}`)).not.toBeInTheDocument(); }); }); + +describe('RelativeChangeByConstituency hex map placement', () => { + /** The chart's hex map of `output`, with the hex grid chosen. */ + async function renderHexMap(output: SocietyWideReportOutput) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return container; + } + + test('given constituencies with codes then the hex map puts each in the hex v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement(UK_LOCAL_AREAS_OUTPUT, 'constituency'); + + // When + const container = await renderHexMap(UK_LOCAL_AREAS_OUTPUT); + + // Then + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + }); + + test('given constituencies with codes then the hex map draws the tiled grid’s pointy-topped hexagons', async () => { + // When + const container = await renderHexMap(UK_LOCAL_AREAS_OUTPUT); + + // Then + expect(v2Hexagons(container).every(isPointyTopped)).toBe(true); + }); + + test('given constituencies without codes then the hex map keeps their results’ positions, as v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement( + UK_LOCAL_AREAS_OUTPUT_NO_CODES, + 'constituency' + ); + + // When + const container = await renderHexMap(UK_LOCAL_AREAS_OUTPUT_NO_CODES); + + // Then: on the square grid of flat-topped hexagons those positions were made for + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); + }); +}); From d130de1b19e670ad668f54a90ec09247fa7608aa Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:55:56 +0400 Subject: [PATCH 02/21] Place v2's local-authority hex maps from the app's layout, as v3 does v2's absolute and relative local-authority hex maps drew each authority at the x and y its result gives, on the untiled square grid. The Public API's positions differ from the layout for 56 of the 361 authorities, and put Cumberland, Westmorland and Furness, North Yorkshire, Somerset and Bournemouth, Christchurch and Poole, created in 2019 and 2023, all at (0, 0), so v2 drew them on top of one another. Both maps now place their areas with placeOnUKHexLayout, as v3's local-area map and v2's constituency maps do: each authority whose ONS code the layout has takes the layout's position, on the tiled grid. An area the layout cannot place keeps its result's position, on the untiled grid. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 8263784e92a9bf9b8fec4aed178efdff8f64b247) --- .../AbsoluteChangeByLocalAuthority.tsx | 6 +- .../RelativeChangeByLocalAuthority.tsx | 6 +- .../localAuthorityComponentMocks.ts | 55 ++++++++++++++ .../report-output/ukHexMapPlacementMocks.tsx | 2 +- .../AbsoluteChangeByLocalAuthority.test.tsx | 72 +++++++++++++++++++ .../RelativeChangeByLocalAuthority.test.tsx | 72 +++++++++++++++++++ 6 files changed, 210 insertions(+), 3 deletions(-) diff --git a/app/src/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.tsx b/app/src/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.tsx index 067792596..d63881236 100644 --- a/app/src/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.tsx +++ b/app/src/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.tsx @@ -11,6 +11,7 @@ import { ukNationFromRegion, type MapVisualizationType, } from '@/components/visualization/choropleth'; +import { placeOnUKHexLayout } from '@/components/visualization/choropleth/ukHexLayout'; import { HexagonalMap } from '@/components/visualization/HexagonalMap'; import type { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK'; import { formatParameterValue } from '@/utils/chartValueUtils'; @@ -46,6 +47,8 @@ export function AbsoluteChangeByLocalAuthority({ () => (areas ? transformLocalAuthorityAbsoluteChange(areas) : []), [areas] ); + // The hex map places each local authority by its code in the app's layout, as v3's does. + const hexMap = useMemo(() => placeOnUKHexLayout('local_authority', mapData), [mapData]); const notes = ; if (!mapData.length) { @@ -92,8 +95,9 @@ export function AbsoluteChangeByLocalAuthority({ /> ) : ( (areas ? transformLocalAuthorityRelativeChange(areas) : []), [areas] ); + // The hex map places each local authority by its code in the app's layout, as v3's does. + const hexMap = useMemo(() => placeOnUKHexLayout('local_authority', mapData), [mapData]); const notes = ; if (!mapData.length) { @@ -92,8 +95,9 @@ export function RelativeChangeByLocalAuthority({ /> ) : ( { const { local_authority_impact: _, ...rest } = MOCK_UK_REPORT_OUTPUT_WITH_LA; return rest; })() as Omit; + +/** Real LAD23 codes: three unitary authorities created in April 2023, and Darlington. */ +export const CUMBERLAND_CODE = 'E06000063'; +export const WESTMORLAND_AND_FURNESS_CODE = 'E06000064'; +export const NORTH_YORKSHIRE_CODE = 'E06000065'; +export const DARLINGTON_CODE = 'E06000005'; + +/** + * Five local authorities at the positions the Public API gives them + * (`local_authorities_2021.csv`). It puts the three created in 2023, + * Cumberland, Westmorland and Furness, and North Yorkshire, all at (0, 0). + */ +export const MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA: ReportOutputSocietyWideUK = { + ...MOCK_UK_REPORT_OUTPUT_WITH_LA, + local_authority_impact: { + by_local_authority: { + Hartlepool: { + x: 8, + y: 19, + average_household_income_change: -210.4, + relative_household_income_change: -0.0081, + code: HARTLEPOOL_CODE, + }, + Cumberland: { + x: 0, + y: 0, + average_household_income_change: -150.2, + relative_household_income_change: -0.0052, + code: CUMBERLAND_CODE, + }, + 'Westmorland and Furness': { + x: 0, + y: 0, + average_household_income_change: -98.6, + relative_household_income_change: -0.0031, + code: WESTMORLAND_AND_FURNESS_CODE, + }, + 'North Yorkshire': { + x: 0, + y: 0, + average_household_income_change: -120.9, + relative_household_income_change: -0.0039, + code: NORTH_YORKSHIRE_CODE, + }, + Darlington: { + x: 7, + y: 18, + average_household_income_change: -185.3, + relative_household_income_change: -0.0072, + code: DARLINGTON_CODE, + }, + }, + }, +}; diff --git a/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx b/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx index 58e0500c9..ba6b0497f 100644 --- a/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx +++ b/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx @@ -20,7 +20,7 @@ export function hexagonVertices(polygon: Element): [number, number][] { } /** A hexagon polygon's centre: the mean of its vertices. */ -function hexagonCentre(polygon: Element): HexagonCentre { +export function hexagonCentre(polygon: Element): HexagonCentre { const vertices = hexagonVertices(polygon); return { x: vertices.reduce((sum, [vertexX]) => sum + vertexX, 0) / vertices.length, diff --git a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx index 567a8963d..659ce68c3 100644 --- a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx +++ b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx @@ -1,5 +1,6 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { AbsoluteChangeByLocalAuthority } from '@/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority'; import { UK_LOCAL_AREAS_OUTPUT, @@ -15,7 +16,16 @@ import { import { MOCK_UK_REPORT_OUTPUT_NO_LA_DATA, MOCK_UK_REPORT_OUTPUT_WITH_LA, + MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, } from '@/tests/fixtures/pages/local-authority/localAuthorityComponentMocks'; +import { + areaNames, + hexagonArrangement, + hexagonCentre, + isPointyTopped, + v2Hexagons, + v3HexagonArrangement, +} from '@/tests/fixtures/pages/report-output/ukHexMapPlacementMocks'; import { withNullAreaImpacts } from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; beforeEach(() => { @@ -124,3 +134,65 @@ describe('AbsoluteChangeByLocalAuthority map type', () => { expect(screen.queryByTestId(`area-${HARTLEPOOL_CODE}`)).not.toBeInTheDocument(); }); }); + +describe('AbsoluteChangeByLocalAuthority hex map placement', () => { + /** The chart's hex map of `output`, with the hex grid chosen. */ + async function renderHexMap(output: SocietyWideReportOutput) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return container; + } + + test('given local authorities the results put in one hex then the hex map draws each in its own', async () => { + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA); + + // Then + const centres = v2Hexagons(container).map((hexagon) => { + const centre = hexagonCentre(hexagon); + return `${centre.x.toFixed(3)},${centre.y.toFixed(3)}`; + }); + expect(new Set(centres).size).toBe( + areaNames(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, 'local_authority').length + ); + }); + + test('given local authorities with codes then the hex map puts each in the hex v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement( + MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, + 'local_authority' + ); + + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA); + + // Then + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + }); + + test('given local authorities with codes then the hex map draws the tiled grid’s pointy-topped hexagons', async () => { + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA); + + // Then + expect(v2Hexagons(container).every(isPointyTopped)).toBe(true); + }); + + test('given local authorities without codes then the hex map keeps their results’ positions, as v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement( + MOCK_UK_REPORT_OUTPUT_WITH_LA, + 'local_authority' + ); + + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_LA); + + // Then: on the square grid of flat-topped hexagons those positions were made for + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx b/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx index 0e0bcd8bf..50d77d991 100644 --- a/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx +++ b/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx @@ -1,5 +1,6 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { RelativeChangeByLocalAuthority } from '@/pages/report-output/local-authority/RelativeChangeByLocalAuthority'; import { UK_LOCAL_AREAS_OUTPUT } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; import { NATIONAL_OUTPUT_WITHOUT_AREAS } from '@/tests/fixtures/components/flagship/report/ukLocalAreaSectionMocks'; @@ -11,7 +12,16 @@ import { import { MOCK_UK_REPORT_OUTPUT_NO_LA_DATA, MOCK_UK_REPORT_OUTPUT_WITH_LA, + MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, } from '@/tests/fixtures/pages/local-authority/localAuthorityComponentMocks'; +import { + areaNames, + hexagonArrangement, + hexagonCentre, + isPointyTopped, + v2Hexagons, + v3HexagonArrangement, +} from '@/tests/fixtures/pages/report-output/ukHexMapPlacementMocks'; import { withNullAreaImpacts } from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; beforeEach(() => { @@ -111,3 +121,65 @@ describe('RelativeChangeByLocalAuthority map type', () => { expect(screen.queryByTestId(`area-${CARDIFF_CODE}`)).not.toBeInTheDocument(); }); }); + +describe('RelativeChangeByLocalAuthority hex map placement', () => { + /** The chart's hex map of `output`, with the hex grid chosen. */ + async function renderHexMap(output: SocietyWideReportOutput) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return container; + } + + test('given local authorities the results put in one hex then the hex map draws each in its own', async () => { + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA); + + // Then + const centres = v2Hexagons(container).map((hexagon) => { + const centre = hexagonCentre(hexagon); + return `${centre.x.toFixed(3)},${centre.y.toFixed(3)}`; + }); + expect(new Set(centres).size).toBe( + areaNames(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, 'local_authority').length + ); + }); + + test('given local authorities with codes then the hex map puts each in the hex v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement( + MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, + 'local_authority' + ); + + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA); + + // Then + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + }); + + test('given local authorities with codes then the hex map draws the tiled grid’s pointy-topped hexagons', async () => { + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA); + + // Then + expect(v2Hexagons(container).every(isPointyTopped)).toBe(true); + }); + + test('given local authorities without codes then the hex map keeps their results’ positions, as v3’s does', async () => { + // Given + const v3Arrangement = await v3HexagonArrangement( + MOCK_UK_REPORT_OUTPUT_WITH_LA, + 'local_authority' + ); + + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_LA); + + // Then: on the square grid of flat-topped hexagons those positions were made for + expect(hexagonArrangement(v2Hexagons(container))).toEqual(v3Arrangement); + expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); + }); +}); From 3a48e68ecc93298a28afb187d49dcb39fc36a98b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:59:01 +0400 Subject: [PATCH 03/21] Say in the layouts README that v2's hex maps use them too The README said only v3's "Local areas" tab draws from the layout files. v2's constituency and local-authority tabs now do as well, through placeOnUKHexLayout. The test fixture that compares v2's and v3's hex maps also takes UKAreaLevel from its adapter instead of repeating the type. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 94f42033b364011d744c3e759d770a7f8779f935) --- app/src/data/ukHexLayouts/README.md | 2 +- .../fixtures/pages/report-output/ukHexMapPlacementMocks.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/data/ukHexLayouts/README.md b/app/src/data/ukHexLayouts/README.md index c3fda58b5..27bff79c9 100644 --- a/app/src/data/ukHexLayouts/README.md +++ b/app/src/data/ukHexLayouts/README.md @@ -1,6 +1,6 @@ # UK hex-map layouts -Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab draws its hex maps from these files, so it no longer depends on the `x` and `y` that the Public API sends with each area. +Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab and the v2 constituency and local authority tabs draw their hex maps from these files, through `placeOnUKHexLayout`, so they no longer depend on the `x` and `y` that the Public API sends with each area. | File | Areas | Boundaries | Source file | |---|---|---|---| diff --git a/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx b/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx index ba6b0497f..5b26060e5 100644 --- a/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx +++ b/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx @@ -1,4 +1,5 @@ import { render, screen, userEvent, within } from '@test-utils'; +import type { UKAreaLevel } from '@/adapters/ukLocalAreaBreakdownAdapter'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import UKLocalAreaMaps from '@/components/flagship/report/UKLocalAreaMaps'; import { MAP_LEGEND_NAME } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; @@ -9,8 +10,6 @@ export interface HexagonCentre { y: number; } -type UKAreaLevel = 'constituency' | 'local_authority'; - /** A hexagon polygon's vertices, in drawing order. */ export function hexagonVertices(polygon: Element): [number, number][] { return (polygon.getAttribute('points') ?? '') From c6f816ff835a6fc77447f1a3fc4e0959cba85e92 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:09:42 +0400 Subject: [PATCH 04/21] Keep tiled hexagons inside the hex map's frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the tiled grid a hexagon's radius is 0.55 of the scale, but the map fitted only the hexagons' centres inside a margin of twice hexSize. A map of few areas has a large scale, so its top and bottom hexagons ran past the frame and were clipped, in the map and in its download. v2's hex map of three Welsh constituencies, now tiled, reached y = -15.6 in its 380-unit frame. v3's map of a nation had the same problem. The tiled map's bounds now take in each hexagon's edges, its radius up and down and √3/2 of it sideways, so the scale leaves room for them. HexMapConfig's hexSize comment now says what it sets on each grid. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 54a674ee8cb882d43f124d233a35a2920e7a9b10) --- .../components/visualization/HexagonalMap.tsx | 19 +++++++++---- .../visualization/HexagonalMap.test.tsx | 28 +++++++++++++++++++ .../AbsoluteChangeByConstituency.test.tsx | 15 ++++++++++ app/src/types/visualization/HexMapConfig.ts | 6 +++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/app/src/components/visualization/HexagonalMap.tsx b/app/src/components/visualization/HexagonalMap.tsx index f31b36ada..78c94ce53 100644 --- a/app/src/components/visualization/HexagonalMap.tsx +++ b/app/src/components/visualization/HexagonalMap.tsx @@ -405,20 +405,27 @@ export function HexagonalMap({ ? (positionedData.find((point) => point.id === selectedId) ?? null) : null; - // Calculate data bounds + // Calculate data bounds. A tiled hexagon's size is a share of the scale, + // so the bounds take in each hexagon's edges, not only its centre: the map + // of a few areas, whose hexagons are large, then still fits its frame. const dataBounds = useMemo(() => { if (positionedData.length === 0) { return { xMin: 0, xMax: 1, yMin: 0, yMax: 1 }; } const xs = positionedData.map((p) => p.x); const ys = positionedData.map((p) => p.y); + // A pointy-top hexagon reaches its radius up and down, and √3/2 of it sideways. + const verticalReach = fullConfig.tiled + ? TILED_HEX_RADIUS * (fullConfig.coordinateScale ?? 1) + : 0; + const horizontalReach = (verticalReach * Math.sqrt(3)) / 2; return { - xMin: Math.min(...xs), - xMax: Math.max(...xs), - yMin: Math.min(...ys), - yMax: Math.max(...ys), + xMin: Math.min(...xs) - horizontalReach, + xMax: Math.max(...xs) + horizontalReach, + yMin: Math.min(...ys) - verticalReach, + yMax: Math.max(...ys) + verticalReach, }; - }, [positionedData]); + }, [positionedData, fullConfig.tiled, fullConfig.coordinateScale]); // Calculate color range const colorValues = positionedData.map((p) => p.value); diff --git a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx index 5413e308e..716e19412 100644 --- a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx +++ b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx @@ -490,6 +490,34 @@ describe('HexagonalMap default view', () => { }); }); +describe('HexagonalMap tiled frame', () => { + test('given a tiled map of a few areas then every hexagon stays inside the frame', () => { + // When: three areas, so each hexagon is drawn large + const { container } = render( + + ); + + // Then + const extent = hexagonExtent(container); + expect(extent.minY).toBeGreaterThanOrEqual(0); + expect(extent.maxY).toBeLessThanOrEqual(380); + expect(extent.minX).toBeGreaterThanOrEqual(0); + }); + + test('given a tiled UK-shaped map then every hexagon stays inside the frame', () => { + // When + const { container } = render( + + ); + + // Then + const extent = hexagonExtent(container); + expect(extent.minY).toBeGreaterThanOrEqual(0); + expect(extent.maxY).toBeLessThanOrEqual(380); + expect(extent.minX).toBeGreaterThanOrEqual(0); + }); +}); + describe('HexagonalMap fillWidth', () => { test('given fillWidth then the map takes the frame width and the legend ends at the logo inset', () => { // Given a frame 1000 pixels wide diff --git a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx index ad50d0e0e..64957be3a 100644 --- a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx @@ -23,6 +23,7 @@ import { } from '@/tests/fixtures/pages/constituency/constituencyComponentMocks'; import { hexagonArrangement, + hexagonVertices, isPointyTopped, v2Hexagons, v3HexagonArrangement, @@ -210,6 +211,20 @@ describe('AbsoluteChangeByConstituency hex map placement', () => { expect(v2Hexagons(container).every(isPointyTopped)).toBe(true); }); + test('given a nation’s few constituencies then the hex map draws every hexagon inside its frame', async () => { + // When: three Welsh constituencies, so each hexagon is drawn large + const container = await renderHexMap(UK_LOCAL_AREAS_OUTPUT); + + // Then + const hexagons = v2Hexagons(container); + const frameHeight = Number((hexagons[0] as SVGElement).ownerSVGElement!.getAttribute('height')); + const vertexYs = hexagons.flatMap((hexagon) => + hexagonVertices(hexagon).map(([, vertexY]) => vertexY) + ); + expect(Math.min(...vertexYs)).toBeGreaterThanOrEqual(0); + expect(Math.max(...vertexYs)).toBeLessThanOrEqual(frameHeight); + }); + test('given constituencies without codes then the hex map keeps their results’ positions, as v3’s does', async () => { // Given const v3Arrangement = await v3HexagonArrangement( diff --git a/app/src/types/visualization/HexMapConfig.ts b/app/src/types/visualization/HexMapConfig.ts index b902c0715..4df7d6087 100644 --- a/app/src/types/visualization/HexMapConfig.ts +++ b/app/src/types/visualization/HexMapConfig.ts @@ -33,7 +33,11 @@ export interface HexMapConfig { /** Height of the plot in pixels */ height?: number; - /** Size of each hexagon marker */ + /** + * On the untiled grid, the size of each hexagon. On either grid, twice it + * is the margin around the map. A tiled hexagon's size follows the scale + * instead, so its neighbours touch. + */ hexSize?: number; /** From 71dc49ef62314163750d2f5b2877601e61e1c5c7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:10:40 +0400 Subject: [PATCH 05/21] Say in the layouts README which areas still use x and y The README said the hex maps no longer depend on the x and y the Public API sends. An area without a code in the layout still falls back to them, so it now says so. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit d2af132180dff183d2a9cd9c4b2fff4550aa9342) --- app/src/data/ukHexLayouts/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/data/ukHexLayouts/README.md b/app/src/data/ukHexLayouts/README.md index 27bff79c9..5ffe93824 100644 --- a/app/src/data/ukHexLayouts/README.md +++ b/app/src/data/ukHexLayouts/README.md @@ -1,6 +1,6 @@ # UK hex-map layouts -Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab and the v2 constituency and local authority tabs draw their hex maps from these files, through `placeOnUKHexLayout`, so they no longer depend on the `x` and `y` that the Public API sends with each area. +Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab and the v2 constituency and local authority tabs place each area from these files by its ONS code, through `placeOnUKHexLayout`, instead of at the `x` and `y` that the Public API sends with each area. An area whose code these files lack, or that has no code, still falls back to its `x` and `y`. | File | Areas | Boundaries | Source file | |---|---|---|---| From 07d096ca4aca89dc6f020a38e5d4837cd54ddfe2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:50:49 +0400 Subject: [PATCH 06/21] Place a UK hex map's areas from the layout all or none When the layout had no position for some of a result's areas, placeOnUKHexLayout moved the others to their layout positions and left those at the x and y their results gave, all on the untiled grid. The two sets of positions are different grids (a row apart for constituencies, entirely different for local authorities), so the map's hexagons overlapped or scattered. A saved result from before April 2023 reaches this: the layout follows the April 2023 boundaries and has no code for a district abolished then, such as Allerdale (E07000026), Craven (E07000163) or Mendip (E07000187). Placement is now all or none. If the layout lacks any point's code, every point keeps its result's position, on the untiled grid; only when it has every code do the points take its positions, tiled. v2's and v3's hex maps both use it. The README and v3's comment say so. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit f4e720cc4031df62f43be77daa6dbd48f12b5189) --- .../flagship/report/UKLocalAreaMaps.tsx | 3 +- .../visualization/choropleth/ukHexLayout.ts | 33 ++++----- app/src/data/ukHexLayouts/README.md | 2 +- .../choropleth/ukHexLayoutMocks.ts | 28 ++++++++ .../localAuthorityComponentMocks.ts | 70 +++++++++++++++++++ .../flagship/report/UKLocalAreaMaps.test.tsx | 32 +++++++++ .../choropleth/ukHexLayout.test.ts | 37 +++++++--- .../AbsoluteChangeByLocalAuthority.test.tsx | 18 ++++- 8 files changed, 195 insertions(+), 28 deletions(-) diff --git a/app/src/components/flagship/report/UKLocalAreaMaps.tsx b/app/src/components/flagship/report/UKLocalAreaMaps.tsx index ff6990418..b52853725 100644 --- a/app/src/components/flagship/report/UKLocalAreaMaps.tsx +++ b/app/src/components/flagship/report/UKLocalAreaMaps.tsx @@ -265,7 +265,8 @@ export default function UKLocalAreaMaps({ ); // The hex map places each area by its code in the app's layout, tiled. - // A result without codes keeps the API's positions on their square grid. + // A result with any area the layout cannot place keeps all the API's + // positions, on their square grid. const hexMap = useMemo(() => placeOnUKHexLayout(level, mapData), [level, mapData]); const decimals = useMemo(() => percentDecimals(mapData.map((point) => point.value)), [mapData]); diff --git a/app/src/components/visualization/choropleth/ukHexLayout.ts b/app/src/components/visualization/choropleth/ukHexLayout.ts index 907336a0e..d88015648 100644 --- a/app/src/components/visualization/choropleth/ukHexLayout.ts +++ b/app/src/components/visualization/choropleth/ukHexLayout.ts @@ -40,25 +40,26 @@ export interface UKHexMapPlacement { } /** - * A level's hex-map points, placed from the app's layout: each point whose - * code the layout has takes that position. A point the layout has no - * position for (no code, as in an older saved result, or a code the layout - * lacks) keeps the position its result gave it, and the map then draws the - * untiled square grid those positions were made for. v2's and v3's UK hex - * maps both place their areas with this. + * A level's hex-map points, placed from the app's layout, all or none. + * When the layout has a position for every point's code, each point takes + * its position, on the tiled grid. When it lacks even one (a point with no + * code, as in an older saved result, or with a code the layout does not + * have, such as a district abolished in April 2023), every point keeps the + * position its result gave it, on the untiled square grid those positions + * were made for: the two sets of positions are different grids, so mixing + * them would overlap or scatter the hexagons. v2's and v3's UK hex maps + * both place their areas with this. */ export function placeOnUKHexLayout( level: UKBoundaryLevel, points: HexMapDataPoint[] ): UKHexMapPlacement { - let tiled = true; - const placed = points.map((point) => { - const position = ukHexPosition(level, point.code); - if (!position) { - tiled = false; - return point; - } - return { ...point, ...position }; - }); - return { points: placed, tiled }; + const positions = points.map((point) => ukHexPosition(level, point.code)); + if (positions.some((position) => position === null)) { + return { points, tiled: false }; + } + return { + points: points.map((point, index) => ({ ...point, ...positions[index] })), + tiled: true, + }; } diff --git a/app/src/data/ukHexLayouts/README.md b/app/src/data/ukHexLayouts/README.md index 5ffe93824..1a778ab7c 100644 --- a/app/src/data/ukHexLayouts/README.md +++ b/app/src/data/ukHexLayouts/README.md @@ -1,6 +1,6 @@ # UK hex-map layouts -Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab and the v2 constituency and local authority tabs place each area from these files by its ONS code, through `placeOnUKHexLayout`, instead of at the `x` and `y` that the Public API sends with each area. An area whose code these files lack, or that has no code, still falls back to its `x` and `y`. +Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab and the v2 constituency and local authority tabs place each area from these files by its ONS code, through `placeOnUKHexLayout`, instead of at the `x` and `y` that the Public API sends with each area. If any area in a result has no code, or a code these files lack (such as a district abolished in April 2023), every area in that result falls back to its `x` and `y`, on the untiled grid, so the map never mixes the two sets of positions. | File | Areas | Boundaries | Source file | |---|---|---|---| diff --git a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts index 86bf6ea0a..a5627a22d 100644 --- a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts +++ b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts @@ -36,3 +36,31 @@ export const UNCODED_POINT: HexMapDataPoint = { x: 3, y: 4, }; + +/** + * Darlington's hex-map point, at the Public API's position (7, 18). The + * layout places it a row higher, at (7, 19), so the point moves when placed. + */ +export const DARLINGTON_POINT: HexMapDataPoint = { + id: 'Darlington', + label: 'Darlington', + value: 45, + x: 7, + y: 18, + code: 'E06000005', +}; + +/** + * Allerdale, a district abolished in April 2023, as an older saved result + * holds it: a code the layout, which follows the April 2023 boundaries, + * does not have. The position is made up. + */ +export const ALLERDALE_CODE = 'E07000026'; +export const ALLERDALE_POINT: HexMapDataPoint = { + id: 'Allerdale', + label: 'Allerdale', + value: -15, + x: 3, + y: 19, + code: ALLERDALE_CODE, +}; diff --git a/app/src/tests/fixtures/pages/local-authority/localAuthorityComponentMocks.ts b/app/src/tests/fixtures/pages/local-authority/localAuthorityComponentMocks.ts index 4d61783a1..c397e43d6 100644 --- a/app/src/tests/fixtures/pages/local-authority/localAuthorityComponentMocks.ts +++ b/app/src/tests/fixtures/pages/local-authority/localAuthorityComponentMocks.ts @@ -260,3 +260,73 @@ export const MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA: ReportOutputSocietyWideUK = }, }, }; + +/** + * Codes of districts abolished in April 2023: Allerdale, Craven and Mendip. + * The layout follows the April 2023 boundaries, so it has none of them. + */ +export const PRE_2023_LOCAL_AUTHORITY_CODES = { + ALLERDALE: 'E07000026', + CRAVEN: 'E07000163', + MENDIP: 'E07000187', +} as const; + +/** + * A saved result from before April 2023: Hartlepool and Darlington on + * current codes at the Public API's positions, and three districts on + * pre-2023 codes at made-up positions on the same grid. + */ +export const MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA: ReportOutputSocietyWideUK = { + ...MOCK_UK_REPORT_OUTPUT_WITH_LA, + local_authority_impact: { + by_local_authority: { + Hartlepool: { + x: 8, + y: 19, + average_household_income_change: -210.4, + relative_household_income_change: -0.0081, + code: HARTLEPOOL_CODE, + }, + Darlington: { + x: 7, + y: 18, + average_household_income_change: -185.3, + relative_household_income_change: -0.0072, + code: DARLINGTON_CODE, + }, + Allerdale: { + x: 3, + y: 19, + average_household_income_change: -140.6, + relative_household_income_change: -0.0049, + code: PRE_2023_LOCAL_AUTHORITY_CODES.ALLERDALE, + }, + Craven: { + x: 6, + y: 17, + average_household_income_change: -101.2, + relative_household_income_change: -0.0033, + code: PRE_2023_LOCAL_AUTHORITY_CODES.CRAVEN, + }, + Mendip: { + x: 0, + y: 1, + average_household_income_change: -88.7, + relative_household_income_change: -0.0029, + code: PRE_2023_LOCAL_AUTHORITY_CODES.MENDIP, + }, + }, + }, +}; + +/** The same result with its codes taken away, so the hex map draws it at its results' positions. */ +export const MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA_UNCODED: ReportOutputSocietyWideUK = { + ...MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA, + local_authority_impact: { + by_local_authority: Object.fromEntries( + Object.entries( + MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA.local_authority_impact!.by_local_authority + ).map(([name, { code: _code, ...area }]) => [name, area]) + ), + }, +}; diff --git a/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx b/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx index 05a6a0798..25b4b5a3e 100644 --- a/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx +++ b/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx @@ -34,7 +34,15 @@ import { NORMALIZED_BREAKDOWN } from '@/tests/fixtures/hooks/useUKLocalAreaBreak import { MOCK_UK_REPORT_OUTPUT_NO_LA_FIELD, MOCK_UK_REPORT_OUTPUT_WITH_LA, + MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA, + MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA_UNCODED, } from '@/tests/fixtures/pages/local-authority/localAuthorityComponentMocks'; +import { + areaNames, + hexagonArrangement, + isPointyTopped, + v3HexagonArrangement, +} from '@/tests/fixtures/pages/report-output/ukHexMapPlacementMocks'; function hexagon(container: HTMLElement, name: string): Element { return container.querySelector(`[data-area-id="${name}"]`)!; @@ -302,6 +310,30 @@ describe('UKLocalAreaMaps geographic map', () => { expect(points[0][1]).toBeCloseTo(points[3][1], 5); }); + test('given a saved result with pre-2023 local authority codes then every authority keeps its result’s position, untiled', async () => { + // Given: the same result without codes, which the map draws at its results' positions + const resultsArrangement = await v3HexagonArrangement( + MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA_UNCODED, + 'local_authority' + ); + + // When + const user = userEvent.setup(); + const { container } = render( + , + 'uk' + ); + await user.click(screen.getByRole('tab', { name: 'Local authorities' })); + await showHexMap(user); + + // Then: one grid for every authority, not layout positions mixed with the results' + const hexagons = areaNames(MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA, 'local_authority').map( + (name) => hexagon(container, name) + ); + expect(hexagonArrangement(hexagons)).toEqual(resultsArrangement); + expect(hexagons.some(isPointyTopped)).toBe(false); + }); + test('given the hex map chosen then switching the area kind keeps it', async () => { // Given const user = userEvent.setup(); diff --git a/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts index 2050b4620..58eec3ddf 100644 --- a/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts +++ b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts @@ -5,9 +5,12 @@ import { ukHexPosition, } from '@/components/visualization/choropleth/ukHexLayout'; import { + ALLERDALE_CODE, + ALLERDALE_POINT, BARNSLEY_NORTH_CODE, BARNSLEY_NORTH_HEX, BARNSLEY_NORTH_POINT, + DARLINGTON_POINT, HARTLEPOOL_CODE, HARTLEPOOL_HEX, HARTLEPOOL_POINT, @@ -74,6 +77,11 @@ describe('ukHexPosition', () => { }); describe('placeOnUKHexLayout', () => { + test('given the pre-2023 local authority code then the layout has no position for it', () => { + // Then + expect(ukHexPosition('local_authority', ALLERDALE_CODE)).toBeNull(); + }); + test('given points whose codes the layout has then each takes its layout position, tiled', () => { // When const placement = placeOnUKHexLayout('constituency', [BARNSLEY_NORTH_POINT]); @@ -97,28 +105,39 @@ describe('placeOnUKHexLayout', () => { }); }); - test('given a point without a code then it keeps its own position and the grid is not tiled', () => { + test('given a point without a code then every point keeps its own position, untiled', () => { // When const placement = placeOnUKHexLayout('constituency', [BARNSLEY_NORTH_POINT, UNCODED_POINT]); + // Then: one grid for every point, not layout positions mixed with the result's + expect(placement).toEqual({ points: [BARNSLEY_NORTH_POINT, UNCODED_POINT], tiled: false }); + }); + + test("given a code the level's layout lacks then every point keeps its own position, untiled", () => { + // When: a local authority code at the constituency level + const placement = placeOnUKHexLayout('constituency', [BARNSLEY_NORTH_POINT, HARTLEPOOL_POINT]); + // Then - expect(placement.points[1]).toEqual(UNCODED_POINT); - expect(placement.tiled).toBe(false); + expect(placement).toEqual({ points: [BARNSLEY_NORTH_POINT, HARTLEPOOL_POINT], tiled: false }); }); - test('given a point without a layout position then the others still take theirs', () => { + test('given a local authority on a pre-2023 code then every point keeps its own position, untiled', () => { // When - const placement = placeOnUKHexLayout('constituency', [BARNSLEY_NORTH_POINT, UNCODED_POINT]); + const placement = placeOnUKHexLayout('local_authority', [DARLINGTON_POINT, ALLERDALE_POINT]); // Then - expect(placement.points[0]).toMatchObject(ukHexPosition('constituency', BARNSLEY_NORTH_CODE)!); + expect(placement).toEqual({ points: [DARLINGTON_POINT, ALLERDALE_POINT], tiled: false }); }); - test("given a code the level's layout lacks then the point keeps its own position", () => { + test('given every code in the layout then no point keeps its own position', () => { // When - const placement = placeOnUKHexLayout('constituency', [HARTLEPOOL_POINT]); + const placement = placeOnUKHexLayout('local_authority', [DARLINGTON_POINT, HARTLEPOOL_POINT]); // Then - expect(placement).toEqual({ points: [HARTLEPOOL_POINT], tiled: false }); + expect(placement.points.map(({ x, y }) => [x, y])).toEqual([ + [7, 19], + [HARTLEPOOL_HEX[0], HARTLEPOOL_HEX[1] + 1], + ]); + expect(placement.tiled).toBe(true); }); }); diff --git a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx index 659ce68c3..fe97636d8 100644 --- a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx +++ b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, userEvent } from '@test-utils'; +import { cleanup, render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { AbsoluteChangeByLocalAuthority } from '@/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority'; @@ -16,6 +16,8 @@ import { import { MOCK_UK_REPORT_OUTPUT_NO_LA_DATA, MOCK_UK_REPORT_OUTPUT_WITH_LA, + MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA, + MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA_UNCODED, MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, } from '@/tests/fixtures/pages/local-authority/localAuthorityComponentMocks'; import { @@ -145,6 +147,20 @@ describe('AbsoluteChangeByLocalAuthority hex map placement', () => { return container; } + test('given a saved result with pre-2023 local authority codes then every authority keeps its result’s position, untiled', async () => { + // Given: the same result without codes, which the map draws at its results' positions + const uncoded = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA_UNCODED); + const resultsArrangement = hexagonArrangement(v2Hexagons(uncoded)); + cleanup(); + + // When + const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA); + + // Then: one grid for every authority, not layout positions mixed with the results' + expect(hexagonArrangement(v2Hexagons(container))).toEqual(resultsArrangement); + expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); + }); + test('given local authorities the results put in one hex then the hex map draws each in its own', async () => { // When const container = await renderHexMap(MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA); From 41cbbeb3db90876710d6a5419eaced4fb1ac20bc Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:50:55 +0400 Subject: [PATCH 07/21] Say "not reported" for UK areas disclosure control withholds The note under the UK local-area maps now reads "N of M constituencies are not reported" (or local authorities), and a withheld boundary's tooltip on the geographic map reads "Not reported", in v3 and in the four v2 charts. Tests for the three v2 charts that had none check the note and the tooltip. Adapted for main: the conflicts were test imports of the layout-note fixture, which main never had, and a v3 section test that checks the stored constituency's hexagon as main does, now with "not reported". (cherry picked from commit 67c46a9926a0664b61cbc658a279abfe2b27548b) Co-Authored-By: Claude Opus 5.5 --- .../adapters/ukLocalAreaBreakdownAdapter.ts | 2 +- .../flagship/report/UKLocalAreaMaps.tsx | 2 +- .../report/UKAreaDisclosureNotes.tsx | 6 +-- .../visualization/choropleth/types.ts | 2 +- .../AbsoluteChangeByConstituency.tsx | 2 +- .../RelativeChangeByConstituency.tsx | 2 +- .../AbsoluteChangeByLocalAuthority.tsx | 2 +- .../RelativeChangeByLocalAuthority.tsx | 2 +- .../choropleth/ukAreaChoroplethMapMocks.ts | 2 +- .../flagship/report/UKLocalAreaMaps.test.tsx | 14 +++---- .../report/UKLocalAreaSection.test.tsx | 18 ++++---- .../report/UKAreaDisclosureNotes.test.tsx | 14 +++---- .../choropleth/UKAreaChoroplethMap.test.tsx | 8 ++-- .../ConstituencySubPage.test.tsx | 4 +- .../LocalAuthoritySubPage.test.tsx | 6 +-- .../AbsoluteChangeByConstituency.test.tsx | 12 +++--- .../RelativeChangeByConstituency.test.tsx | 41 +++++++++++++++++++ .../AbsoluteChangeByLocalAuthority.test.tsx | 37 +++++++++++++++++ .../RelativeChangeByLocalAuthority.test.tsx | 38 +++++++++++++++++ 19 files changed, 165 insertions(+), 49 deletions(-) diff --git a/app/src/adapters/ukLocalAreaBreakdownAdapter.ts b/app/src/adapters/ukLocalAreaBreakdownAdapter.ts index 550eec09e..6455abf7b 100644 --- a/app/src/adapters/ukLocalAreaBreakdownAdapter.ts +++ b/app/src/adapters/ukLocalAreaBreakdownAdapter.ts @@ -136,7 +136,7 @@ export type UKWithheldAreas = { withheld: number; total: number } | null; export type UKBreakdownWithheldAreas = Partial>; /** - * The areas of a level a breakdown did not publish, out of the level's areas + * The areas of a level a breakdown did not report, out of the level's areas * in the report's region; null when the app cannot count them. * * - A UK-wide report counts against the level's areas in the region list diff --git a/app/src/components/flagship/report/UKLocalAreaMaps.tsx b/app/src/components/flagship/report/UKLocalAreaMaps.tsx index b52853725..24c9bbfbd 100644 --- a/app/src/components/flagship/report/UKLocalAreaMaps.tsx +++ b/app/src/components/flagship/report/UKLocalAreaMaps.tsx @@ -392,7 +392,7 @@ export default function UKLocalAreaMaps({ focusNation={ukNationFromRegion(region)} exportRef={mapRef} // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld ? 'Not published' : undefined} + missingAreaText={withheld ? 'Not reported' : undefined} navigable selectedId={selectedArea ? selectedName : null} onAreaClick={toggleSelected} diff --git a/app/src/components/report/UKAreaDisclosureNotes.tsx b/app/src/components/report/UKAreaDisclosureNotes.tsx index 0bce550da..bdbd3a381 100644 --- a/app/src/components/report/UKAreaDisclosureNotes.tsx +++ b/app/src/components/report/UKAreaDisclosureNotes.tsx @@ -19,13 +19,13 @@ function capitalize(text: string): string { function withheldNote(level: UKAreaLevel, withheld: UKWithheldAreas): string | null { const noun = UK_AREA_NOUNS[level]; if (withheld === null) { - return `${capitalize(noun.many)} whose estimates fail the survey data’s disclosure rules are not published. The rules ${WITHHOLDING_RULE}`; + return `${capitalize(noun.many)} whose estimates fail the survey data’s disclosure rules are not reported. The rules ${WITHHOLDING_RULE}`; } if (withheld.withheld <= 0) { return null; } const verb = withheld.withheld === 1 ? 'is' : 'are'; - return `${withheld.withheld} of ${withheld.total} ${noun.many} ${verb} not published. The survey data’s disclosure rules ${WITHHOLDING_RULE}`; + return `${withheld.withheld} of ${withheld.total} ${noun.many} ${verb} not reported. The survey data’s disclosure rules ${WITHHOLDING_RULE}`; } function approximateNote(level: UKAreaLevel, count: number): string | null { @@ -40,7 +40,7 @@ function approximateNote(level: UKAreaLevel, count: number): string | null { /** * Notes under a UK local-area map from a disclosure-controlled breakdown: - * how many of the level's areas were not published and why, and how many + * how many of the level's areas were not reported and why, and how many * shown areas are approximate. A saved result (no `withheld`, no tiers) * gets no notes. */ diff --git a/app/src/components/visualization/choropleth/types.ts b/app/src/components/visualization/choropleth/types.ts index 2e213e95f..12486d56b 100644 --- a/app/src/components/visualization/choropleth/types.ts +++ b/app/src/components/visualization/choropleth/types.ts @@ -151,7 +151,7 @@ export interface UKAreaChoroplethMapProps { /** Optional ref for image export: the map's frame with the boundaries' licence credit under it */ exportRef?: React.Ref; /** - * What an area with no result reads in its tooltip, e.g. "Not published" + * What an area with no result reads in its tooltip, e.g. "Not reported" * for one disclosure control withheld. Defaults to "No data". */ missingAreaText?: string; diff --git a/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx b/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx index fee987edb..5924b10c5 100644 --- a/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx +++ b/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx @@ -88,7 +88,7 @@ export function AbsoluteChangeByConstituency({ focusNation={ukNationFromRegion(region)} exportRef={mapRef} // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld === undefined ? undefined : 'Not published'} + missingAreaText={withheld === undefined ? undefined : 'Not reported'} /> ) : ( ) : ( ) : ( ) : ( { expect(await boundary(MONMOUTHSHIRE_CODE)).toHaveAttribute('data-selected', 'true'); }); - test('given a breakdown that withheld an area then its boundary reads not published', async () => { + test('given a breakdown that withheld an area then its boundary reads not reported', async () => { // Given render( { // Then const tooltip = screen.getByRole('tooltip'); expect(within(tooltip).getByText('Cardiff East')).toBeInTheDocument(); - expect(within(tooltip).getByText(NOT_PUBLISHED_TEXT)).toBeInTheDocument(); + expect(within(tooltip).getByText(NOT_REPORTED_TEXT)).toBeInTheDocument(); }); test('given a saved result without an area then its boundary reads no data, not withheld', async () => { @@ -599,7 +599,7 @@ describe('UKLocalAreaMaps disclosure-controlled areas', () => { const approximateConstituency = NORMALIZED_BREAKDOWN.constituency_impact.by_constituency['East Kilbride and Strathaven']; - test('given withheld areas then each map says how many of its areas were not published', async () => { + test('given withheld areas then each map says how many of its areas were not reported', async () => { const user = userEvent.setup(); render( { 'uk' ); - expect(screen.getByText(/^12 of 650 constituencies are not published\./)).toBeInTheDocument(); + expect(screen.getByText(/^12 of 650 constituencies are not reported\./)).toBeInTheDocument(); await user.click(screen.getByRole('tab', { name: 'Local authorities' })); - expect(screen.getByText(/^2 of 361 local authorities are not published\./)).toBeInTheDocument(); + expect(screen.getByText(/^2 of 361 local authorities are not reported\./)).toBeInTheDocument(); }); test('given local authorities that cannot be counted then the map says why areas may be missing, with no count', async () => { @@ -653,7 +653,7 @@ describe('UKLocalAreaMaps disclosure-controlled areas', () => { 'uk' ); - expect(screen.getByText(/^650 of 650 constituencies are not published\./)).toBeInTheDocument(); + expect(screen.getByText(/^650 of 650 constituencies are not reported\./)).toBeInTheDocument(); }); test('given an approximate area then the map notes it and its details mark it with its 90% ranges', async () => { diff --git a/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx b/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx index 77a4996a0..ceb3a8737 100644 --- a/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx +++ b/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx @@ -151,7 +151,7 @@ describe('UKLocalAreaSection', () => { expect(mockFetchLocalAreaBreakdown).not.toHaveBeenCalled(); }); - test('given a UK-wide breakdown complete then the map says how many constituencies were not published', async () => { + test('given a UK-wide breakdown complete then the map says how many constituencies were not reported', async () => { // Given const user = userEvent.setup(); mockFetchLocalAreaBreakdown.mockResolvedValue(COMPLETE_RESPONSE); @@ -162,25 +162,25 @@ describe('UKLocalAreaSection', () => { // Then expect( - await screen.findByText(/^2 of 3 constituencies are not published\./) + await screen.findByText(/^2 of 3 constituencies are not reported\./) ).toBeInTheDocument(); }); - test('given a UK-wide breakdown complete then the local authority map says how many local authorities were not published', async () => { + test('given a UK-wide breakdown complete then the local authority map says how many local authorities were not reported', async () => { // Given const user = userEvent.setup(); mockFetchLocalAreaBreakdown.mockResolvedValue(COMPLETE_RESPONSE); renderSection(); await user.click(screen.getByRole('button', { name: COMPUTE_BUTTON_LABEL })); expect( - await screen.findByText(/^2 of 3 constituencies are not published\./) + await screen.findByText(/^2 of 3 constituencies are not reported\./) ).toBeInTheDocument(); // When await user.click(screen.getByRole('tab', { name: 'Local authorities' })); // Then - expect(screen.getByText(/^2 of 3 local authorities are not published\./)).toBeInTheDocument(); + expect(screen.getByText(/^2 of 3 local authorities are not reported\./)).toBeInTheDocument(); }); test('given a breakdown that publishes a local authority the region list does not name then that map has no count and the constituency map keeps its own', async () => { @@ -190,7 +190,7 @@ describe('UKLocalAreaSection', () => { renderSection(); await user.click(screen.getByRole('button', { name: COMPUTE_BUTTON_LABEL })); expect( - await screen.findByText(/^2 of 3 constituencies are not published\./) + await screen.findByText(/^2 of 3 constituencies are not reported\./) ).toBeInTheDocument(); // When @@ -200,7 +200,7 @@ describe('UKLocalAreaSection', () => { expect( screen.getByText(/^Local authorities whose estimates fail the survey data’s disclosure rules/) ).toBeInTheDocument(); - expect(screen.queryByText(/local authorities are not published\./)).not.toBeInTheDocument(); + expect(screen.queryByText(/local authorities are not reported\./)).not.toBeInTheDocument(); }); test('given saved area data then no disclosure note shows', async () => { @@ -208,7 +208,7 @@ describe('UKLocalAreaSection', () => { renderSection(STORED_OUTPUT_WITH_AREAS); // Then - expect(screen.queryByText(/not published/)).not.toBeInTheDocument(); + expect(screen.queryByText(/not reported/)).not.toBeInTheDocument(); // The geographic map has loaded its boundaries. expect(await screen.findByRole('img', { name: MAP_LEGEND_NAME })).toBeInTheDocument(); @@ -235,7 +235,7 @@ describe('UKLocalAreaSection', () => { ); await user.click(screen.getByRole('tab', { name: 'Constituencies' })); expect(container.querySelector(`[data-area-id="${STORED_CONSTITUENCY_NAME}"]`)).not.toBeNull(); - expect(screen.queryByText(/constituencies are not published/)).not.toBeInTheDocument(); + expect(screen.queryByText(/constituencies are not reported/)).not.toBeInTheDocument(); }); test('given an older stored result with constituencies but no local authorities then it says there is no local authority data and offers no compute', async () => { diff --git a/app/src/tests/unit/components/report/UKAreaDisclosureNotes.test.tsx b/app/src/tests/unit/components/report/UKAreaDisclosureNotes.test.tsx index bee4795ef..554e5ab24 100644 --- a/app/src/tests/unit/components/report/UKAreaDisclosureNotes.test.tsx +++ b/app/src/tests/unit/components/report/UKAreaDisclosureNotes.test.tsx @@ -13,7 +13,7 @@ const CONSTITUENCIES = NORMALIZED_BREAKDOWN.constituency_impact.by_constituency; const LOCAL_AUTHORITIES = NORMALIZED_BREAKDOWN.local_authority_impact.by_local_authority; describe('UKAreaDisclosureNotes', () => { - test('given counted withheld constituencies then says how many were not published and why', () => { + test('given counted withheld constituencies then says how many were not reported and why', () => { // When render( { // Then expect( screen.getByText( - '12 of 650 constituencies are not published. The survey data’s disclosure rules withhold an area’s estimate when too few surveyed households support it, or when it could reveal a household’s income.' + '12 of 650 constituencies are not reported. The survey data’s disclosure rules withhold an area’s estimate when too few surveyed households support it, or when it could reveal a household’s income.' ) ).toBeInTheDocument(); }); - test('given counted withheld local authorities then says how many were not published and why, as for constituencies', () => { + test('given counted withheld local authorities then says how many were not reported and why, as for constituencies', () => { // When render( { // Then expect( screen.getByText( - '2 of 361 local authorities are not published. The survey data’s disclosure rules withhold an area’s estimate when too few surveyed households support it, or when it could reveal a household’s income.' + '2 of 361 local authorities are not reported. The survey data’s disclosure rules withhold an area’s estimate when too few surveyed households support it, or when it could reveal a household’s income.' ) ).toBeInTheDocument(); }); @@ -63,7 +63,7 @@ describe('UKAreaDisclosureNotes', () => { ); // Then - expect(screen.getByText(/^1 of 650 constituencies is not published\./)).toBeInTheDocument(); + expect(screen.getByText(/^1 of 650 constituencies is not reported\./)).toBeInTheDocument(); }); test('given withheld areas that cannot be counted then says why areas may be missing, with no count', () => { @@ -76,7 +76,7 @@ describe('UKAreaDisclosureNotes', () => { // Then expect( screen.getByText( - 'Local authorities whose estimates fail the survey data’s disclosure rules are not published. The rules withhold an area’s estimate when too few surveyed households support it, or when it could reveal a household’s income.' + 'Local authorities whose estimates fail the survey data’s disclosure rules are not reported. The rules withhold an area’s estimate when too few surveyed households support it, or when it could reveal a household’s income.' ) ).toBeInTheDocument(); }); @@ -93,7 +93,7 @@ describe('UKAreaDisclosureNotes', () => { ); // Then - expect(screen.queryByText(/not published/)).not.toBeInTheDocument(); + expect(screen.queryByText(/not reported/)).not.toBeInTheDocument(); }); test('given a saved result with no disclosure control then there is no note', () => { diff --git a/app/src/tests/unit/components/visualization/choropleth/UKAreaChoroplethMap.test.tsx b/app/src/tests/unit/components/visualization/choropleth/UKAreaChoroplethMap.test.tsx index 342fa0273..55decfe39 100644 --- a/app/src/tests/unit/components/visualization/choropleth/UKAreaChoroplethMap.test.tsx +++ b/app/src/tests/unit/components/visualization/choropleth/UKAreaChoroplethMap.test.tsx @@ -16,7 +16,7 @@ import { MOCK_UK_CONSTITUENCY_POINTS_WITHOUT_CODES, mockUKBoundaryFetch, MONMOUTHSHIRE_CODE, - NOT_PUBLISHED_TEXT, + NOT_REPORTED_TEXT, POWYS_CODE, RENAMED_AREA_POINT, UK_CONSTITUENCY_BOUNDARY_PATH, @@ -96,13 +96,13 @@ describe('UKAreaChoroplethMap', () => { expect(await boundary(CLWYD_EAST_CODE)).toHaveAttribute('fill', NO_DATA_FILL); }); - test('given a withheld area then its boundary is grey and reads not published', async () => { + test('given a withheld area then its boundary is grey and reads not reported', async () => { // Given render( ); const cardiffEast = await boundary(CARDIFF_EAST_CODE); @@ -112,7 +112,7 @@ describe('UKAreaChoroplethMap', () => { // Then: the boundary file names it, as the result does not. expect(cardiffEast).toHaveAttribute('fill', NO_DATA_FILL); - expect(tooltipLines()).toEqual(['Cardiff East', NOT_PUBLISHED_TEXT]); + expect(tooltipLines()).toEqual(['Cardiff East', NOT_REPORTED_TEXT]); }); test('given an approximate area with a 90% range then its tooltip gives both', async () => { diff --git a/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx b/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx index 47c1e6f72..8b34e3a15 100644 --- a/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx @@ -112,7 +112,7 @@ describe('ConstituencySubPage', () => { expect(screen.getByText(NOT_AVAILABLE)).toBeInTheDocument(); }); - test('given the areas computed for a UK-wide report then the charts say how many constituencies were not published', async () => { + test('given the areas computed for a UK-wide report then the charts say how many constituencies were not reported', async () => { // Given const user = userEvent.setup(); mockFetchLocalAreaBreakdown.mockResolvedValue(COMPLETE_RESPONSE); @@ -128,7 +128,7 @@ describe('ConstituencySubPage', () => { // Then expect( - await screen.findByText(/^2 of 3 constituencies are not published\./) + await screen.findByText(/^2 of 3 constituencies are not reported\./) ).toBeInTheDocument(); expect(screen.getByText(/^1 constituency is approximate:/)).toBeInTheDocument(); }); diff --git a/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx b/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx index fa4de3bf5..b9f16f834 100644 --- a/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx @@ -166,7 +166,7 @@ describe('LocalAuthoritySubPage', () => { ).toBeInTheDocument(); }); - test('given the areas computed for a UK-wide report then the charts say how many local authorities were not published', async () => { + test('given the areas computed for a UK-wide report then the charts say how many local authorities were not reported', async () => { // Given const user = userEvent.setup(); mockFetchLocalAreaBreakdown.mockResolvedValue(COMPLETE_RESPONSE); @@ -182,7 +182,7 @@ describe('LocalAuthoritySubPage', () => { // Then expect( - await screen.findByText(/^2 of 3 local authorities are not published\./) + await screen.findByText(/^2 of 3 local authorities are not reported\./) ).toBeInTheDocument(); }); @@ -205,7 +205,7 @@ describe('LocalAuthoritySubPage', () => { // Then expect( await screen.findByText( - /^Local authorities whose estimates fail the survey data’s disclosure rules are not published\./ + /^Local authorities whose estimates fail the survey data’s disclosure rules are not reported\./ ) ).toBeInTheDocument(); }); diff --git a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx index 64957be3a..e2a0f5c3d 100644 --- a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx @@ -15,7 +15,7 @@ import { GOWER_CODE, MAP_LEGEND_NAME, mockUKBoundaryFetch, - NOT_PUBLISHED_TEXT, + NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; import { MOCK_UK_REPORT_OUTPUT, @@ -101,7 +101,7 @@ describe('AbsoluteChangeByConstituency', () => { expect(screen.getByText('No constituency data available')).toBeInTheDocument(); }); - test('given withheld constituencies then the chart says how many were not published', async () => { + test('given withheld constituencies then the chart says how many were not reported', async () => { // When render( { ); // Then - expect(screen.getByText(/^12 of 650 constituencies are not published\./)).toBeInTheDocument(); + expect(screen.getByText(/^12 of 650 constituencies are not reported\./)).toBeInTheDocument(); // The geographic map has loaded its boundaries. expect(await screen.findByRole('img', { name: MAP_LEGEND_NAME })).toBeInTheDocument(); @@ -122,7 +122,7 @@ describe('AbsoluteChangeByConstituency', () => { render(); // Then - expect(screen.queryByText(/not published/)).not.toBeInTheDocument(); + expect(screen.queryByText(/not reported/)).not.toBeInTheDocument(); // The geographic map has loaded its boundaries. expect(await screen.findByRole('img', { name: MAP_LEGEND_NAME })).toBeInTheDocument(); @@ -163,7 +163,7 @@ describe('AbsoluteChangeByConstituency map type', () => { expect(screen.queryByTestId(`area-${ALDERSHOT_CODE}`)).not.toBeInTheDocument(); }); - test('given withheld constituencies then a boundary without a result reads not published', async () => { + test('given withheld constituencies then a boundary without a result reads not reported', async () => { // Given const user = userEvent.setup(); render( @@ -178,7 +178,7 @@ describe('AbsoluteChangeByConstituency map type', () => { await user.hover(await screen.findByTestId(`area-${CARDIFF_EAST_CODE}`)); // Then - expect(screen.getByRole('tooltip')).toHaveTextContent(`Cardiff East${NOT_PUBLISHED_TEXT}`); + expect(screen.getByRole('tooltip')).toHaveTextContent(`Cardiff East${NOT_REPORTED_TEXT}`); }); }); diff --git a/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx index a6e1540e6..ad3c0f52f 100644 --- a/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx @@ -5,12 +5,16 @@ import { RelativeChangeByConstituency } from '@/pages/report-output/constituency import { UK_LOCAL_AREAS_OUTPUT, UK_LOCAL_AREAS_OUTPUT_NO_CODES, + WALES_REGION, } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; import { NATIONAL_OUTPUT_WITHOUT_AREAS } from '@/tests/fixtures/components/flagship/report/ukLocalAreaSectionMocks'; +import { SOME_WITHHELD_CONSTITUENCIES } from '@/tests/fixtures/components/report/UKLocalAreaComputePanelMocks'; import { + CARDIFF_EAST_CODE, GOWER_CODE, MAP_LEGEND_NAME, mockUKBoundaryFetch, + NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; import { MOCK_UK_REPORT_OUTPUT, @@ -166,3 +170,40 @@ describe('RelativeChangeByConstituency hex map placement', () => { expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); }); }); + +describe('RelativeChangeByConstituency disclosure control', () => { + test('given withheld constituencies then the chart says how many were not reported', async () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/^12 of 650 constituencies are not reported\./)).toBeInTheDocument(); + + // The geographic map has loaded its boundaries. + expect(await screen.findByRole('img', { name: MAP_LEGEND_NAME })).toBeInTheDocument(); + }); + + test('given withheld constituencies then a boundary without a result reads not reported', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.hover(await screen.findByTestId(`area-${CARDIFF_EAST_CODE}`)); + + // Then + expect(screen.getByRole('tooltip')).toHaveTextContent(`Cardiff East${NOT_REPORTED_TEXT}`); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx index fe97636d8..1756fbd41 100644 --- a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx +++ b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx @@ -7,11 +7,13 @@ import { WALES_REGION, } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; import { NATIONAL_OUTPUT_WITHOUT_AREAS } from '@/tests/fixtures/components/flagship/report/ukLocalAreaSectionMocks'; +import { SOME_WITHHELD_LOCAL_AUTHORITIES } from '@/tests/fixtures/components/report/UKLocalAreaComputePanelMocks'; import { CARDIFF_CODE, HARTLEPOOL_CODE, MAP_LEGEND_NAME, mockUKBoundaryFetch, + NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; import { MOCK_UK_REPORT_OUTPUT_NO_LA_DATA, @@ -212,3 +214,38 @@ describe('AbsoluteChangeByLocalAuthority hex map placement', () => { expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); }); }); + +describe('AbsoluteChangeByLocalAuthority disclosure control', () => { + test('given withheld local authorities then the chart says how many were not reported', async () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/^2 of 361 local authorities are not reported\./)).toBeInTheDocument(); + + // The geographic map has loaded its boundaries. + expect(await screen.findByRole('img', { name: MAP_LEGEND_NAME })).toBeInTheDocument(); + }); + + test('given withheld local authorities then a boundary without a result reads not reported', async () => { + // Given: a UK-wide report, whose map draws Hartlepool, which has no result + const user = userEvent.setup(); + render( + + ); + + // When + await user.hover(await screen.findByTestId(`area-${HARTLEPOOL_CODE}`)); + + // Then + expect(screen.getByRole('tooltip')).toHaveTextContent(`Hartlepool${NOT_REPORTED_TEXT}`); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx b/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx index 50d77d991..052fc958f 100644 --- a/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx +++ b/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx @@ -4,10 +4,13 @@ import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { RelativeChangeByLocalAuthority } from '@/pages/report-output/local-authority/RelativeChangeByLocalAuthority'; import { UK_LOCAL_AREAS_OUTPUT } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; import { NATIONAL_OUTPUT_WITHOUT_AREAS } from '@/tests/fixtures/components/flagship/report/ukLocalAreaSectionMocks'; +import { SOME_WITHHELD_LOCAL_AUTHORITIES } from '@/tests/fixtures/components/report/UKLocalAreaComputePanelMocks'; import { CARDIFF_CODE, + HARTLEPOOL_CODE, MAP_LEGEND_NAME, mockUKBoundaryFetch, + NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; import { MOCK_UK_REPORT_OUTPUT_NO_LA_DATA, @@ -183,3 +186,38 @@ describe('RelativeChangeByLocalAuthority hex map placement', () => { expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); }); }); + +describe('RelativeChangeByLocalAuthority disclosure control', () => { + test('given withheld local authorities then the chart says how many were not reported', async () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/^2 of 361 local authorities are not reported\./)).toBeInTheDocument(); + + // The geographic map has loaded its boundaries. + expect(await screen.findByRole('img', { name: MAP_LEGEND_NAME })).toBeInTheDocument(); + }); + + test('given withheld local authorities then a boundary without a result reads not reported', async () => { + // Given: a UK-wide report, whose map draws Hartlepool, which has no result + const user = userEvent.setup(); + render( + + ); + + // When + await user.hover(await screen.findByTestId(`area-${HARTLEPOOL_CODE}`)); + + // Then + expect(screen.getByRole('tooltip')).toHaveTextContent(`Hartlepool${NOT_REPORTED_TEXT}`); + }); +}); From b1c66a380de8044fbe110a9b5d0e49ff0fd58dda Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:53:56 +0400 Subject: [PATCH 08/21] Draw areas without a value on the hex map, hatched HexagonalMap takes `missingAreas`: areas it places but has no value for, such as ones disclosure control withheld. Each is drawn in its place with grey stripes on the map's background, which no colour on the scale resembles, and its tooltip reads its name and `missingAreaText` ("No data" by default). Missing areas take no part in the colour range, cannot be selected, and the map's frame fits them with the valued areas. Adapted for main, where HexagonalMap has an untiled grid beside the tiled one: missing areas take the same grid as the valued areas (positionOnGrid, shared by both), are pointy-topped when tiled, and are fitted with the tiled reach. The tests draw them on the tiled grid, as the UK hex maps do. (cherry picked from commit 211701e9c0b797784db66c72de6cf01446cb65d1) Co-Authored-By: Claude Opus 5.5 --- .../components/visualization/HexagonalMap.tsx | 153 +++++++++++++----- .../components/visualization/hexMapMocks.ts | 24 ++- .../visualization/HexagonalMap.test.tsx | 144 +++++++++++++++++ .../types/visualization/HexMapDataPoint.ts | 18 +++ app/src/utils/visualization/hexMapUtils.ts | 4 +- 5 files changed, 305 insertions(+), 38 deletions(-) diff --git a/app/src/components/visualization/HexagonalMap.tsx b/app/src/components/visualization/HexagonalMap.tsx index 78c94ce53..9030cb757 100644 --- a/app/src/components/visualization/HexagonalMap.tsx +++ b/app/src/components/visualization/HexagonalMap.tsx @@ -3,7 +3,7 @@ import { ChartWatermark } from '@/components/charts'; import { Button } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import type { HexMapConfig } from '@/types/visualization/HexMapConfig'; -import type { HexMapDataPoint } from '@/types/visualization/HexMapDataPoint'; +import type { HexMapDataPoint, HexMapMissingArea } from '@/types/visualization/HexMapDataPoint'; import { getColorScale, interpolateColor } from '@/utils/visualization/colorScales'; import { applyHexagonalPositioning, @@ -15,6 +15,17 @@ interface HexagonalMapProps { /** Array of data points to visualize */ data: HexMapDataPoint[]; + /** + * Opt-in: areas the map places but has no value for, e.g. ones disclosure + * control withheld. Each is drawn hatched, outside the colour scale, with + * a tooltip of its name and `missingAreaText`. They take no part in the + * colour range, and cannot be selected. + */ + missingAreas?: HexMapMissingArea[]; + + /** What a missing area's tooltip reads after its name. Defaults to "No data". */ + missingAreaText?: string; + /** Configuration for the map */ config?: Partial; @@ -283,6 +294,22 @@ const TILED_ROW_SPACING = Math.sqrt(3) / 2; /** A tiled hexagon's radius, in columns: just under 1/√3, so neighbours keep a hairline gap. */ const TILED_HEX_RADIUS = 0.55; +/** A map with no missing areas: one array, so memos keep their values. */ +const NO_MISSING_AREAS: HexMapMissingArea[] = []; + +/** What a missing area reads, unless the caller says why it has no value. */ +const DEFAULT_MISSING_AREA_TEXT = 'No data'; + +/** + * A missing area's hatching: grey stripes on the map's background, which + * no colour on the scale resembles. The stripes repeat at half a hexagon's + * radius, so each hexagon shows a few at any size. + */ +const MISSING_AREA_GROUND_COLOR = colors.background.primary; +const MISSING_AREA_STRIPE_COLOR = colors.gray[300]; +const MISSING_AREA_STRIPES_PER_RADIUS = 2; +const MIN_MISSING_AREA_STRIPE_SPACING = 2; + /** * SVG polygon points for a hexagon centred at (cx, cy): flat-topped, or * pointy-topped for a tiled grid. @@ -320,6 +347,26 @@ const FRAME_INSET = 8; /** The SVG's width in viewBox units when it does not fill the frame. */ const FIXED_SVG_WIDTH = 600; +/** + * Points on the map's grid: even rows shifted half a column, then the + * coordinates scaled and the rows spaced as the grid needs. + */ +function positionOnGrid( + points: Point[], + coordinateScale: number, + rowSpacing: number +): Point[] { + const hexPositioned = applyHexagonalPositioning(points); + if (coordinateScale === 1 && rowSpacing === 1) { + return hexPositioned; + } + return hexPositioned.map((point) => ({ + ...point, + x: point.x * coordinateScale, + y: point.y * rowSpacing * coordinateScale, + })); +} + /** The rendered width of `element`, in CSS pixels, kept up to date; null until measured. */ function useElementWidth(element: Element | null, enabled: boolean): number | null { const [width, setWidth] = useState(null); @@ -339,6 +386,8 @@ function useElementWidth(element: Element | null, enabled: boolean): number | nu export function HexagonalMap({ data, + missingAreas = NO_MISSING_AREAS, + missingAreaText = DEFAULT_MISSING_AREA_TEXT, config = {}, exportRef, selectedId, @@ -348,7 +397,9 @@ export function HexagonalMap({ centered = false, fillWidth = false, }: HexagonalMapProps) { - const clipId = `hex-map-clip-${useId().replace(/:/g, '')}`; + const mapId = useId().replace(/:/g, ''); + const clipId = `hex-map-clip-${mapId}`; + const hatchingId = `hex-map-missing-area-hatching-${mapId}`; const [tooltip, setTooltip] = useState<{ text: string; x: number; @@ -373,22 +424,18 @@ export function HexagonalMap({ [config] ); - // Transform data for hexagonal positioning and apply coordinate scaling - const positionedData = useMemo(() => { - const hexPositioned = applyHexagonalPositioning(data); - const scale = fullConfig.coordinateScale ?? 1; - const rowSpacing = fullConfig.tiled ? TILED_ROW_SPACING : 1; - - if (scale === 1 && rowSpacing === 1) { - return hexPositioned; - } - - return hexPositioned.map((point) => ({ - ...point, - x: point.x * scale, - y: point.y * rowSpacing * scale, - })); - }, [data, fullConfig.coordinateScale, fullConfig.tiled]); + // Transform data for hexagonal positioning and apply coordinate scaling. + // Missing areas take the same grid as the areas with values. + const coordinateScale = fullConfig.coordinateScale ?? 1; + const rowSpacing = fullConfig.tiled ? TILED_ROW_SPACING : 1; + const positionedData = useMemo( + () => positionOnGrid(data, coordinateScale, rowSpacing), + [data, coordinateScale, rowSpacing] + ); + const positionedMissingAreas = useMemo( + () => positionOnGrid(missingAreas, coordinateScale, rowSpacing), + [missingAreas, coordinateScale, rowSpacing] + ); // The selected hexagon draws last, so no neighbour covers its outline. const drawOrder = useMemo(() => { @@ -405,15 +452,17 @@ export function HexagonalMap({ ? (positionedData.find((point) => point.id === selectedId) ?? null) : null; - // Calculate data bounds. A tiled hexagon's size is a share of the scale, - // so the bounds take in each hexagon's edges, not only its centre: the map - // of a few areas, whose hexagons are large, then still fits its frame. + // Calculate data bounds, over the areas with values and without. A tiled + // hexagon's size is a share of the scale, so the bounds take in each + // hexagon's edges, not only its centre: the map of a few areas, whose + // hexagons are large, then still fits its frame. const dataBounds = useMemo(() => { - if (positionedData.length === 0) { + const placed = [...positionedData, ...positionedMissingAreas]; + if (placed.length === 0) { return { xMin: 0, xMax: 1, yMin: 0, yMax: 1 }; } - const xs = positionedData.map((p) => p.x); - const ys = positionedData.map((p) => p.y); + const xs = placed.map((p) => p.x); + const ys = placed.map((p) => p.y); // A pointy-top hexagon reaches its radius up and down, and √3/2 of it sideways. const verticalReach = fullConfig.tiled ? TILED_HEX_RADIUS * (fullConfig.coordinateScale ?? 1) @@ -425,9 +474,9 @@ export function HexagonalMap({ yMin: Math.min(...ys) - verticalReach, yMax: Math.max(...ys) + verticalReach, }; - }, [positionedData, fullConfig.tiled, fullConfig.coordinateScale]); + }, [positionedData, positionedMissingAreas, fullConfig.tiled, fullConfig.coordinateScale]); - // Calculate color range + // Calculate color range: from the areas with values alone const colorValues = positionedData.map((p) => p.value); const colorRange = useMemo(() => { if (colorValues.length === 0) { @@ -466,6 +515,10 @@ export function HexagonalMap({ const hexPixelSize = tiled ? scale * (fullConfig.coordinateScale ?? 1) * TILED_HEX_RADIUS : Math.min(hexSize * (scale / 20), scale * 0.45); + const stripeSpacing = Math.max( + MIN_MISSING_AREA_STRIPE_SPACING, + hexPixelSize / MISSING_AREA_STRIPES_PER_RADIUS + ); const formatTick = fullConfig.formatColorBarTick ?? defaultFormatTick; const ticks = colorBarTicks(colorRange.min, colorRange.max); @@ -501,8 +554,45 @@ export function HexagonalMap({ // The selected outline keeps its width in screen pixels at any zoom. const outlineEffect = zoomable ? 'non-scaling-stroke' : undefined; + const showTooltip = (event: React.MouseEvent, text: string) => { + const rect = (event.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect(); + setTooltip({ text, x: event.clientX - rect.left, y: event.clientY - rect.top - 10 }); + }; + const mapLayer = ( <> + {/* Areas without a value: hatched, outside the colour scale, and not selectable. */} + {positionedMissingAreas.length > 0 && ( + + + + + + + )} + {positionedMissingAreas.map((area) => ( + showTooltip(event, `${area.label}: ${missingAreaText}`)} + onMouseLeave={() => setTooltip(null)} + /> + ))} + {/* Hexagon data points */} {drawOrder.map((point) => { const cx = toSvgX(point.x); @@ -523,16 +613,7 @@ export function HexagonalMap({ onClick={onSelect ? () => onSelect(point.id) : undefined} data-area-id={onSelect ? point.id : undefined} data-selected={isSelected || undefined} - onMouseEnter={(e) => { - const rect = ( - e.currentTarget.ownerSVGElement as SVGSVGElement - ).getBoundingClientRect(); - setTooltip({ - text: hoverText, - x: e.clientX - rect.left, - y: e.clientY - rect.top - 10, - }); - }} + onMouseEnter={(event) => showTooltip(event, hoverText)} onMouseLeave={() => setTooltip(null)} /> ); diff --git a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts index ba34b431e..428fc9b10 100644 --- a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts +++ b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts @@ -1,4 +1,4 @@ -import type { HexMapDataPoint } from '@/types/visualization/HexMapDataPoint'; +import type { HexMapDataPoint, HexMapMissingArea } from '@/types/visualization/HexMapDataPoint'; export const WESTMINSTER_NORTH: HexMapDataPoint = { id: 'westminster-north', @@ -80,3 +80,25 @@ export const MOCK_TILED_HEX_DATA: HexMapDataPoint[] = [ { id: 'east', label: 'East', value: 20, x: 1, y: 0 }, { id: 'north', label: 'North', value: 30, x: 0, y: 1 }, ]; + +/** An area without a value, in the next row up from MOCK_TILED_HEX_DATA's, beside North. */ +export const MISSING_NORTH_EAST: HexMapMissingArea = { + id: 'north-east', + label: 'North East', + x: 1, + y: 1, +}; + +/** + * An area without a value, twenty columns east of MOCK_TILED_HEX_DATA's: + * a map that fits only its valued areas would draw it outside its frame. + */ +export const MISSING_FAR_EAST: HexMapMissingArea = { + id: 'far-east', + label: 'Far East', + x: 20, + y: 0, +}; + +/** The text a missing area's tooltip reads when the caller says why it has no value. */ +export const NOT_REPORTED_TEXT = 'Not reported'; diff --git a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx index 716e19412..01c8b2574 100644 --- a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx +++ b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx @@ -2,12 +2,15 @@ import { fireEvent, render, screen, userEvent } from '@test-utils'; import { describe, expect, test, vi } from 'vitest'; import { HexagonalMap, zoomAbout } from '@/components/visualization/HexagonalMap'; import { + MISSING_FAR_EAST, + MISSING_NORTH_EAST, MOCK_HEX_MAP_DATA, MOCK_MIXED_VALUES, MOCK_NEGATIVE_VALUES, MOCK_POSITIVE_VALUES, MOCK_TILED_HEX_DATA, MOCK_UK_SHAPED_HEX_DATA, + NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/hexMapMocks'; /** A hexagon polygon's vertices, in drawing order. */ @@ -554,3 +557,144 @@ describe('HexagonalMap fillWidth', () => { expect(container.querySelector('svg')!.getAttribute('viewBox')).toBe('0 0 600 380'); }); }); + +describe('HexagonalMap missing areas', () => { + /** The tiled grid, on which the UK hex maps draw the areas they have no value for. */ + const TILED_CONFIG = { tiled: true }; + + /** A missing area's hexagon, by its id. */ + function missingHexagon(container: HTMLElement, id: string): Element | null { + return container.querySelector(`[data-missing-area-id="${id}"]`); + } + + test('given a missing area then it is drawn in its place, hatched rather than a colour on the scale', () => { + // When + const { container } = render( + + ); + + // Then + const hexagon = missingHexagon(container, MISSING_NORTH_EAST.id)!; + const hatching = container.querySelector('pattern')!; + expect(hexagon).toBeInTheDocument(); + expect(hexagon.getAttribute('fill')).toBe(`url(#${hatching.id})`); + expect(container.querySelectorAll('svg polygon')).toHaveLength(4); + }); + + test('given a missing area then it sits in the row above, beside the area west of it', () => { + // When + const { container } = render( + + ); + + // Then: one column east of North, in North's row + const [west, east, north] = ['west', 'east', 'north'].map( + (id) => container.querySelector(`[data-area-id="${id}"]`)! + ); + const northEast = missingHexagon(container, MISSING_NORTH_EAST.id)!; + expect(centre(northEast)[1]).toBeCloseTo(centre(north)[1], 5); + expect(centre(northEast)[0] - centre(north)[0]).toBeCloseTo( + centre(east)[0] - centre(west)[0], + 5 + ); + }); + + test('given a missing area hovered then its tooltip gives its name and why it has no value', async () => { + // Given + const user = userEvent.setup(); + const { container } = render( + + ); + + // When + await user.hover(missingHexagon(container, MISSING_NORTH_EAST.id)!); + + // Then + expect(screen.getByText(`North East: ${NOT_REPORTED_TEXT}`)).toBeInTheDocument(); + }); + + test('given no missing-area text then a missing area reads no data', async () => { + // Given + const user = userEvent.setup(); + const { container } = render( + + ); + + // When + await user.hover(missingHexagon(container, MISSING_NORTH_EAST.id)!); + + // Then + expect(screen.getByText('North East: No data')).toBeInTheDocument(); + }); + + test('given a selectable map then a missing area is not selectable', async () => { + // Given + const user = userEvent.setup(); + const onSelect = vi.fn(); + const { container } = render( + + ); + const hexagon = missingHexagon(container, MISSING_NORTH_EAST.id)!; + + // When + await user.click(hexagon); + + // Then + expect(onSelect).not.toHaveBeenCalled(); + expect(hexagon).not.toHaveAttribute('data-area-id'); + }); + + test('given a missing area far from the valued ones then the map fits it in its frame too', () => { + // When + const { container } = render( + + ); + + // Then + const svg = container.querySelector('svg')!; + const [, , frameWidth, frameHeight] = svg.getAttribute('viewBox')!.split(' ').map(Number); + const hexagons = [...container.querySelectorAll('svg polygon')]; + const allVertices = hexagons.flatMap(vertices); + expect(missingHexagon(container, MISSING_FAR_EAST.id)).toBeInTheDocument(); + expect(Math.min(...allVertices.map(([vertexX]) => vertexX))).toBeGreaterThanOrEqual(0); + expect(Math.max(...allVertices.map(([vertexX]) => vertexX))).toBeLessThanOrEqual(frameWidth); + expect(Math.min(...allVertices.map(([, vertexY]) => vertexY))).toBeGreaterThanOrEqual(0); + expect(Math.max(...allVertices.map(([, vertexY]) => vertexY))).toBeLessThanOrEqual(frameHeight); + }); + + test('given no missing areas then the map draws no hatching', () => { + // When + const { container } = render(); + + // Then + expect(container.querySelector('pattern')).not.toBeInTheDocument(); + expect(container.querySelector('[data-missing-area-id]')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/types/visualization/HexMapDataPoint.ts b/app/src/types/visualization/HexMapDataPoint.ts index 983d7dcac..c416eb8a2 100644 --- a/app/src/types/visualization/HexMapDataPoint.ts +++ b/app/src/types/visualization/HexMapDataPoint.ts @@ -41,3 +41,21 @@ export interface HexMapDataPoint { lower?: number; upper?: number; } + +/** + * An area the hex map places but has no value for, such as one disclosure + * control withheld. The map draws it hatched, outside its colour scale. + */ +export interface HexMapMissingArea { + /** Unique identifier for this area, e.g. its code */ + id: string; + + /** Display label (e.g., constituency name) */ + label: string; + + /** X-coordinate on the hexagonal grid (0-based) */ + x: number; + + /** Y-coordinate on the hexagonal grid (0-based) */ + y: number; +} diff --git a/app/src/utils/visualization/hexMapUtils.ts b/app/src/utils/visualization/hexMapUtils.ts index 19e0aa7ca..3c528a60d 100644 --- a/app/src/utils/visualization/hexMapUtils.ts +++ b/app/src/utils/visualization/hexMapUtils.ts @@ -25,7 +25,9 @@ import type { HexMapDataPoint } from '@/types/visualization/HexMapDataPoint'; * // positioned[2].x === 0 (odd row unchanged) * ``` */ -export function applyHexagonalPositioning(points: HexMapDataPoint[]): HexMapDataPoint[] { +export function applyHexagonalPositioning( + points: Point[] +): Point[] { return points.map((point) => ({ ...point, x: point.y % 2 === 0 ? point.x + 0.5 : point.x, From 4dd708414c5b81277349f513bdd6262a8d804360 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:56:06 +0400 Subject: [PATCH 09/21] List the areas of a report's region that a UK result leaves out withheldUKHexAreas gives the constituencies or local authorities of a report's region that a result does not hold, each at its hex layout position and named, with its ONS code as its id: every area of the layout for a UK-wide report, and the nation's for a nation's report. It lists none when the result cannot be matched to the layout: an area with no code, a code outside the region's layout, or a region that is neither the UK nor a nation. The names come from two new files beside the layouts, built from the boundary files' PCON24NM and LAD23NM, which a test checks against them. Adapted for main, whose hex-map points still carry the result's x and y: withheldUKHexAreas reads only each point's code, and the Welsh test points carry the Public API's positions. Its documentation says that when placeOnUKHexLayout cannot place every area, and the map keeps the result's own positions, it lists none, so withheld areas are drawn only on the tiled grid. (cherry picked from commit bc4dade16e71aa8a42fa91598f328deda149439e) Co-Authored-By: Claude Opus 5.5 --- .../visualization/choropleth/ukHexLayout.ts | 62 +- app/src/data/ukHexLayouts/README.md | 13 +- .../ukHexLayouts/constituency_names_2024.json | 652 ++++++++++++++++++ .../local_authority_names_2023.json | 363 ++++++++++ .../choropleth/ukHexLayoutMocks.ts | 25 + .../choropleth/ukHexLayout.test.ts | 105 +++ .../tests/unit/data/ukBoundaryFiles.test.ts | 21 +- 7 files changed, 1234 insertions(+), 7 deletions(-) create mode 100644 app/src/data/ukHexLayouts/constituency_names_2024.json create mode 100644 app/src/data/ukHexLayouts/local_authority_names_2023.json diff --git a/app/src/components/visualization/choropleth/ukHexLayout.ts b/app/src/components/visualization/choropleth/ukHexLayout.ts index d88015648..139ac12fb 100644 --- a/app/src/components/visualization/choropleth/ukHexLayout.ts +++ b/app/src/components/visualization/choropleth/ukHexLayout.ts @@ -1,13 +1,17 @@ /** * The UK hex maps' layouts: one hex per area, keyed by ONS code, from Open - * Innovations' HexJSON files. Their source, licence and update procedure are - * in data/ukHexLayouts/README.md. + * Innovations' HexJSON files, and each area's name, from the boundary + * files. Their sources, licences and update procedure are in + * data/ukHexLayouts/README.md. */ import constituencies from '@/data/ukHexLayouts/constituencies_2024.json'; +import constituencyNames from '@/data/ukHexLayouts/constituency_names_2024.json'; import localAuthorities from '@/data/ukHexLayouts/local_authorities_2023.json'; -import type { HexMapDataPoint } from '@/types/visualization/HexMapDataPoint'; +import localAuthorityNames from '@/data/ukHexLayouts/local_authority_names_2023.json'; +import type { HexMapDataPoint, HexMapMissingArea } from '@/types/visualization/HexMapDataPoint'; import type { UKBoundaryLevel } from './types'; +import { ukNationFromCode, ukNationFromRegion } from './ukAreaBoundaries'; /** Each level's `[q, r]` HexJSON `odd-r` position, by ONS code. */ export const UK_HEX_LAYOUTS: Record> = { @@ -15,6 +19,16 @@ export const UK_HEX_LAYOUTS: Record> = local_authority: localAuthorities, }; +/** + * Each level's area names, by ONS code: the boundary files' PCON24NM and + * LAD23NM, which are .py 6.10.0's names. The hex map names the areas a + * result leaves out with them. + */ +export const UK_HEX_AREA_NAMES: Record> = { + constituency: constituencyNames, + local_authority: localAuthorityNames, +}; + /** * An area's position on the hex map's grid, from its ONS code; null when the * layout has no such code. @@ -63,3 +77,45 @@ export function placeOnUKHexLayout( tiled: true, }; } + +/** + * The areas of a report's region that a result leaves out, as hex-map + * areas without a value: each at its layout position, named from the + * boundary files, with its ONS code as its id. For a disclosure-controlled + * breakdown these are the areas disclosure control withheld. + * + * The region's areas are the layout's: all of them for a UK-wide report + * (`uk`, or no region), and the nation's for a nation's report, as + * countWithheldUKAreas counts a nation's against UK_AREA_COUNTS_BY_NATION. + * None are listed when the result cannot be matched to them: an area with + * no code (an older saved result), a code outside the region's layout + * (such as a district abolished in April 2023), or a region that is + * neither the UK nor a nation. So a map that keeps its result's own + * positions, because placeOnUKHexLayout could not place every area, never + * gets any: they are drawn only on the tiled grid. + */ +export function withheldUKHexAreas( + level: UKBoundaryLevel, + points: Pick[], + region?: string | null +): HexMapMissingArea[] { + const nation = ukNationFromRegion(region); + if (region && region.toLowerCase() !== 'uk' && !nation) { + return []; + } + const layout = UK_HEX_LAYOUTS[level]; + const inRegion = (code: string) => + Object.hasOwn(layout, code) && (!nation || ukNationFromCode(code) === nation); + if (points.some((point) => !point.code || !inRegion(point.code))) { + return []; + } + const reportedCodes = new Set(points.map((point) => point.code)); + return Object.keys(layout) + .filter((code) => inRegion(code) && !reportedCodes.has(code)) + .flatMap((code) => { + const position = ukHexPosition(level, code); + return position + ? [{ id: code, label: UK_HEX_AREA_NAMES[level][code] ?? code, ...position }] + : []; + }); +} diff --git a/app/src/data/ukHexLayouts/README.md b/app/src/data/ukHexLayouts/README.md index 1a778ab7c..bef665e5b 100644 --- a/app/src/data/ukHexLayouts/README.md +++ b/app/src/data/ukHexLayouts/README.md @@ -2,26 +2,33 @@ Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab and the v2 constituency and local authority tabs place each area from these files by its ONS code, through `placeOnUKHexLayout`, instead of at the `x` and `y` that the Public API sends with each area. If any area in a result has no code, or a code these files lack (such as a district abolished in April 2023), every area in that result falls back to its `x` and `y`, on the untiled grid, so the map never mixes the two sets of positions. +`withheldUKHexAreas` lists the areas of a report's region that a result leaves out, each at its position here and named from the name files below. The region's areas are every code in these files for a UK-wide report, and the nation's codes for a nation's report. + | File | Areas | Boundaries | Source file | |---|---|---|---| | `constituencies_2024.json` | 650 Westminster constituencies | 2024 (`PCON24CD`) | [`maps/uk-constituencies-2023.hexjson`](https://github.com/odileeds/hexmaps/blob/4d3152010cc5c54841a6a75de6cb4d6fdd31d223/maps/uk-constituencies-2023.hexjson) at `4d31520` (18 July 2024) | | `local_authorities_2023.json` | 361 local authority districts | April 2023 (`LAD23CD`) | [`maps/uk-local-authority-districts-2023.hexjson`](https://github.com/odileeds/hexmaps/blob/c67a3c9189c6b27efd2218897bb98ee135e9719f/maps/uk-local-authority-districts-2023.hexjson) at `c67a3c9` (30 October 2024) | +| `constituency_names_2024.json` | 650 constituency names | 2024 (`PCON24NM`) | `public/data/geojson/uk_constituencies_2024.geojson` | +| `local_authority_names_2023.json` | 361 local authority names | April 2023 (`LAD23NM`) | `public/data/geojson/uk_local_authorities_2023.geojson` | The constituency source file is named after the 2023 boundary review, whose constituencies took effect at the July 2024 general election: the same 650 codes as `public/data/geojson/uk_constituencies_2024.geojson`. ## Format -Each file maps an ONS code to `[q, r]`, the HexJSON `odd-r` position: `q` is the column, `r` the row, rows increase northwards, and odd rows sit half a hex to the right. `ukHexPosition` in `components/visualization/choropleth/ukHexLayout.ts` converts a position to the hex map's grid. The files keep only the codes and positions; the source files also carry names, regions and colours. +Each layout file maps an ONS code to `[q, r]`, the HexJSON `odd-r` position: `q` is the column, `r` the row, rows increase northwards, and odd rows sit half a hex to the right. `ukHexPosition` in `components/visualization/choropleth/ukHexLayout.ts` converts a position to the hex map's grid. The files keep only the codes and positions; the source files also carry names, regions and colours. + +Each name file maps an ONS code to the area's name in the app's boundary file (`PCON24NM` or `LAD23NM`), which is also its name in .py 6.10.0's area lists. ## Updating 1. Download the new HexJSON file from [odileeds/hexmaps](https://github.com/odileeds/hexmaps) (`maps/`) at a fixed commit. 2. Rebuild the file here as `{ code: [q, r] }`, sorted by code, and update the table above. -3. Run `bun run vitest -- src/tests/unit/data/ukBoundaryFiles.test.ts`. It checks that the codes match the boundary files exactly and that no two areas share a position. +3. Rebuild the name file from the boundary file as `{ code: name }`, sorted by code. +4. Run `bun run vitest -- src/tests/unit/data/ukBoundaryFiles.test.ts`. It checks that the codes match the boundary files exactly, that no two areas share a position, and that each name file gives the boundary file's name for every code. ## Licence -The layouts are by Open Innovations, under the MIT licence: +The name files come from the boundary files, whose source and licence are in `public/data/geojson/SOURCES.md`. The layouts are by Open Innovations, under the MIT licence: ``` MIT License diff --git a/app/src/data/ukHexLayouts/constituency_names_2024.json b/app/src/data/ukHexLayouts/constituency_names_2024.json new file mode 100644 index 000000000..6ad33b5c5 --- /dev/null +++ b/app/src/data/ukHexLayouts/constituency_names_2024.json @@ -0,0 +1,652 @@ +{ + "E14001063": "Aldershot", + "E14001064": "Aldridge-Brownhills", + "E14001065": "Altrincham and Sale West", + "E14001066": "Amber Valley", + "E14001067": "Arundel and South Downs", + "E14001068": "Ashfield", + "E14001069": "Ashford", + "E14001070": "Ashton-under-Lyne", + "E14001071": "Aylesbury", + "E14001072": "Banbury", + "E14001073": "Barking", + "E14001074": "Barnsley North", + "E14001075": "Barnsley South", + "E14001076": "Barrow and Furness", + "E14001077": "Basildon and Billericay", + "E14001078": "Basingstoke", + "E14001079": "Bassetlaw", + "E14001080": "Bath", + "E14001081": "Battersea", + "E14001082": "Beaconsfield", + "E14001083": "Beckenham and Penge", + "E14001084": "Bedford", + "E14001085": "Bermondsey and Old Southwark", + "E14001086": "Bethnal Green and Stepney", + "E14001087": "Beverley and Holderness", + "E14001088": "Bexhill and Battle", + "E14001089": "Bexleyheath and Crayford", + "E14001090": "Bicester and Woodstock", + "E14001091": "Birkenhead", + "E14001092": "Birmingham Edgbaston", + "E14001093": "Birmingham Erdington", + "E14001094": "Birmingham Hall Green and Moseley", + "E14001095": "Birmingham Hodge Hill and Solihull North", + "E14001096": "Birmingham Ladywood", + "E14001097": "Birmingham Northfield", + "E14001098": "Birmingham Perry Barr", + "E14001099": "Birmingham Selly Oak", + "E14001100": "Birmingham Yardley", + "E14001101": "Bishop Auckland", + "E14001102": "Blackburn", + "E14001103": "Blackley and Middleton South", + "E14001104": "Blackpool North and Fleetwood", + "E14001105": "Blackpool South", + "E14001106": "Blaydon and Consett", + "E14001107": "Blyth and Ashington", + "E14001108": "Bognor Regis and Littlehampton", + "E14001109": "Bolsover", + "E14001110": "Bolton North East", + "E14001111": "Bolton South and Walkden", + "E14001112": "Bolton West", + "E14001113": "Bootle", + "E14001114": "Boston and Skegness", + "E14001115": "Bournemouth East", + "E14001116": "Bournemouth West", + "E14001117": "Bracknell", + "E14001118": "Bradford East", + "E14001119": "Bradford South", + "E14001120": "Bradford West", + "E14001121": "Braintree", + "E14001122": "Brent East", + "E14001123": "Brent West", + "E14001124": "Brentford and Isleworth", + "E14001125": "Brentwood and Ongar", + "E14001126": "Bridgwater", + "E14001127": "Bridlington and The Wolds", + "E14001128": "Brigg and Immingham", + "E14001129": "Brighton Kemptown and Peacehaven", + "E14001130": "Brighton Pavilion", + "E14001131": "Bristol Central", + "E14001132": "Bristol East", + "E14001133": "Bristol North East", + "E14001134": "Bristol North West", + "E14001135": "Bristol South", + "E14001136": "Broadland and Fakenham", + "E14001137": "Bromley and Biggin Hill", + "E14001138": "Bromsgrove", + "E14001139": "Broxbourne", + "E14001140": "Broxtowe", + "E14001141": "Buckingham and Bletchley", + "E14001142": "Burnley", + "E14001143": "Burton and Uttoxeter", + "E14001144": "Bury North", + "E14001145": "Bury South", + "E14001146": "Bury St Edmunds and Stowmarket", + "E14001147": "Calder Valley", + "E14001148": "Camborne and Redruth", + "E14001149": "Cambridge", + "E14001150": "Cannock Chase", + "E14001151": "Canterbury", + "E14001152": "Carlisle", + "E14001153": "Carshalton and Wallington", + "E14001154": "Castle Point", + "E14001155": "Central Devon", + "E14001156": "Central Suffolk and North Ipswich", + "E14001157": "Chatham and Aylesford", + "E14001158": "Cheadle", + "E14001159": "Chelmsford", + "E14001160": "Chelsea and Fulham", + "E14001161": "Cheltenham", + "E14001162": "Chesham and Amersham", + "E14001163": "Chester North and Neston", + "E14001164": "Chester South and Eddisbury", + "E14001165": "Chesterfield", + "E14001166": "Chichester", + "E14001167": "Chingford and Woodford Green", + "E14001168": "Chippenham", + "E14001169": "Chipping Barnet", + "E14001170": "Chorley", + "E14001171": "Christchurch", + "E14001172": "Cities of London and Westminster", + "E14001173": "City of Durham", + "E14001174": "Clacton", + "E14001175": "Clapham and Brixton Hill", + "E14001176": "Colchester", + "E14001177": "Colne Valley", + "E14001178": "Congleton", + "E14001179": "Corby and East Northamptonshire", + "E14001180": "Coventry East", + "E14001181": "Coventry North West", + "E14001182": "Coventry South", + "E14001183": "Cramlington and Killingworth", + "E14001184": "Crawley", + "E14001185": "Crewe and Nantwich", + "E14001186": "Croydon East", + "E14001187": "Croydon South", + "E14001188": "Croydon West", + "E14001189": "Dagenham and Rainham", + "E14001190": "Darlington", + "E14001191": "Dartford", + "E14001192": "Daventry", + "E14001193": "Derby North", + "E14001194": "Derby South", + "E14001195": "Derbyshire Dales", + "E14001196": "Dewsbury and Batley", + "E14001197": "Didcot and Wantage", + "E14001198": "Doncaster Central", + "E14001199": "Doncaster East and the Isle of Axholme", + "E14001200": "Doncaster North", + "E14001201": "Dorking and Horley", + "E14001202": "Dover and Deal", + "E14001203": "Droitwich and Evesham", + "E14001204": "Dudley", + "E14001205": "Dulwich and West Norwood", + "E14001206": "Dunstable and Leighton Buzzard", + "E14001207": "Ealing Central and Acton", + "E14001208": "Ealing North", + "E14001209": "Ealing Southall", + "E14001210": "Earley and Woodley", + "E14001211": "Easington", + "E14001212": "East Grinstead and Uckfield", + "E14001213": "East Ham", + "E14001214": "East Hampshire", + "E14001215": "East Surrey", + "E14001216": "East Thanet", + "E14001217": "East Wiltshire", + "E14001218": "East Worthing and Shoreham", + "E14001219": "Eastbourne", + "E14001220": "Eastleigh", + "E14001221": "Edmonton and Winchmore Hill", + "E14001222": "Ellesmere Port and Bromborough", + "E14001223": "Eltham and Chislehurst", + "E14001224": "Ely and East Cambridgeshire", + "E14001225": "Enfield North", + "E14001226": "Epping Forest", + "E14001227": "Epsom and Ewell", + "E14001228": "Erewash", + "E14001229": "Erith and Thamesmead", + "E14001230": "Esher and Walton", + "E14001231": "Exeter", + "E14001232": "Exmouth and Exeter East", + "E14001233": "Fareham and Waterlooville", + "E14001234": "Farnham and Bordon", + "E14001235": "Faversham and Mid Kent", + "E14001236": "Feltham and Heston", + "E14001237": "Filton and Bradley Stoke", + "E14001238": "Finchley and Golders Green", + "E14001239": "Folkestone and Hythe", + "E14001240": "Forest of Dean", + "E14001241": "Frome and East Somerset", + "E14001242": "Fylde", + "E14001243": "Gainsborough", + "E14001244": "Gateshead Central and Whickham", + "E14001245": "Gedling", + "E14001246": "Gillingham and Rainham", + "E14001247": "Glastonbury and Somerton", + "E14001248": "Gloucester", + "E14001249": "Godalming and Ash", + "E14001250": "Goole and Pocklington", + "E14001251": "Gorton and Denton", + "E14001252": "Gosport", + "E14001253": "Grantham and Bourne", + "E14001254": "Gravesham", + "E14001255": "Great Grimsby and Cleethorpes", + "E14001256": "Great Yarmouth", + "E14001257": "Greenwich and Woolwich", + "E14001258": "Guildford", + "E14001259": "Hackney North and Stoke Newington", + "E14001260": "Hackney South and Shoreditch", + "E14001261": "Halesowen", + "E14001262": "Halifax", + "E14001263": "Hamble Valley", + "E14001264": "Hammersmith and Chiswick", + "E14001265": "Hampstead and Highgate", + "E14001266": "Harborough, Oadby and Wigston", + "E14001267": "Harlow", + "E14001268": "Harpenden and Berkhamsted", + "E14001269": "Harrogate and Knaresborough", + "E14001270": "Harrow East", + "E14001271": "Harrow West", + "E14001272": "Hartlepool", + "E14001273": "Harwich and North Essex", + "E14001274": "Hastings and Rye", + "E14001275": "Havant", + "E14001276": "Hayes and Harlington", + "E14001277": "Hazel Grove", + "E14001278": "Hemel Hempstead", + "E14001279": "Hendon", + "E14001280": "Henley and Thame", + "E14001281": "Hereford and South Herefordshire", + "E14001282": "Herne Bay and Sandwich", + "E14001283": "Hertford and Stortford", + "E14001284": "Hertsmere", + "E14001285": "Hexham", + "E14001286": "Heywood and Middleton North", + "E14001287": "High Peak", + "E14001288": "Hinckley and Bosworth", + "E14001289": "Hitchin", + "E14001290": "Holborn and St Pancras", + "E14001291": "Honiton and Sidmouth", + "E14001292": "Hornchurch and Upminster", + "E14001293": "Hornsey and Friern Barnet", + "E14001294": "Horsham", + "E14001295": "Houghton and Sunderland South", + "E14001296": "Hove and Portslade", + "E14001297": "Huddersfield", + "E14001298": "Huntingdon", + "E14001299": "Hyndburn", + "E14001300": "Ilford North", + "E14001301": "Ilford South", + "E14001302": "Ipswich", + "E14001303": "Isle of Wight East", + "E14001304": "Isle of Wight West", + "E14001305": "Islington North", + "E14001306": "Islington South and Finsbury", + "E14001307": "Jarrow and Gateshead East", + "E14001308": "Keighley and Ilkley", + "E14001309": "Kenilworth and Southam", + "E14001310": "Kensington and Bayswater", + "E14001311": "Kettering", + "E14001312": "Kingston and Surbiton", + "E14001313": "Kingston upon Hull East", + "E14001314": "Kingston upon Hull North and Cottingham", + "E14001315": "Kingston upon Hull West and Haltemprice", + "E14001316": "Kingswinford and South Staffordshire", + "E14001317": "Knowsley", + "E14001318": "Lancaster and Wyre", + "E14001319": "Leeds Central and Headingley", + "E14001320": "Leeds East", + "E14001321": "Leeds North East", + "E14001322": "Leeds North West", + "E14001323": "Leeds South", + "E14001324": "Leeds South West and Morley", + "E14001325": "Leeds West and Pudsey", + "E14001326": "Leicester East", + "E14001327": "Leicester South", + "E14001328": "Leicester West", + "E14001329": "Leigh and Atherton", + "E14001330": "Lewes", + "E14001331": "Lewisham East", + "E14001332": "Lewisham North", + "E14001333": "Lewisham West and East Dulwich", + "E14001334": "Leyton and Wanstead", + "E14001335": "Lichfield", + "E14001336": "Lincoln", + "E14001337": "Liverpool Garston", + "E14001338": "Liverpool Riverside", + "E14001339": "Liverpool Walton", + "E14001340": "Liverpool Wavertree", + "E14001341": "Liverpool West Derby", + "E14001342": "Loughborough", + "E14001343": "Louth and Horncastle", + "E14001344": "Lowestoft", + "E14001345": "Luton North", + "E14001346": "Luton South and South Bedfordshire", + "E14001347": "Macclesfield", + "E14001348": "Maidenhead", + "E14001349": "Maidstone and Malling", + "E14001350": "Makerfield", + "E14001351": "Maldon", + "E14001352": "Manchester Central", + "E14001353": "Manchester Rusholme", + "E14001354": "Manchester Withington", + "E14001355": "Mansfield", + "E14001356": "Melksham and Devizes", + "E14001357": "Melton and Syston", + "E14001358": "Meriden and Solihull East", + "E14001359": "Mid Bedfordshire", + "E14001360": "Mid Buckinghamshire", + "E14001361": "Mid Cheshire", + "E14001362": "Mid Derbyshire", + "E14001363": "Mid Dorset and North Poole", + "E14001364": "Mid Leicestershire", + "E14001365": "Mid Norfolk", + "E14001366": "Mid Sussex", + "E14001367": "Middlesbrough and Thornaby East", + "E14001368": "Middlesbrough South and East Cleveland", + "E14001369": "Milton Keynes Central", + "E14001370": "Milton Keynes North", + "E14001371": "Mitcham and Morden", + "E14001372": "Morecambe and Lunesdale", + "E14001373": "New Forest East", + "E14001374": "New Forest West", + "E14001375": "Newark", + "E14001376": "Newbury", + "E14001377": "Newcastle upon Tyne Central and West", + "E14001378": "Newcastle upon Tyne East and Wallsend", + "E14001379": "Newcastle upon Tyne North", + "E14001380": "Newcastle-under-Lyme", + "E14001381": "Newton Abbot", + "E14001382": "Newton Aycliffe and Spennymoor", + "E14001383": "Normanton and Hemsworth", + "E14001384": "North Bedfordshire", + "E14001385": "North Cornwall", + "E14001386": "North Cotswolds", + "E14001387": "North Devon", + "E14001388": "North Dorset", + "E14001389": "North Durham", + "E14001390": "North East Cambridgeshire", + "E14001391": "North East Derbyshire", + "E14001392": "North East Hampshire", + "E14001393": "North East Hertfordshire", + "E14001394": "North East Somerset and Hanham", + "E14001395": "North Herefordshire", + "E14001396": "North Norfolk", + "E14001397": "North Northumberland", + "E14001398": "North Shropshire", + "E14001399": "North Somerset", + "E14001400": "North Warwickshire and Bedworth", + "E14001401": "North West Cambridgeshire", + "E14001402": "North West Essex", + "E14001403": "North West Hampshire", + "E14001404": "North West Leicestershire", + "E14001405": "North West Norfolk", + "E14001406": "Northampton North", + "E14001407": "Northampton South", + "E14001408": "Norwich North", + "E14001409": "Norwich South", + "E14001410": "Nottingham East", + "E14001411": "Nottingham North and Kimberley", + "E14001412": "Nottingham South", + "E14001413": "Nuneaton", + "E14001414": "Old Bexley and Sidcup", + "E14001415": "Oldham East and Saddleworth", + "E14001416": "Oldham West, Chadderton and Royton", + "E14001417": "Orpington", + "E14001418": "Ossett and Denby Dale", + "E14001419": "Oxford East", + "E14001420": "Oxford West and Abingdon", + "E14001421": "Peckham", + "E14001422": "Pendle and Clitheroe", + "E14001423": "Penistone and Stocksbridge", + "E14001424": "Penrith and Solway", + "E14001425": "Peterborough", + "E14001426": "Plymouth Moor View", + "E14001427": "Plymouth Sutton and Devonport", + "E14001428": "Pontefract, Castleford and Knottingley", + "E14001429": "Poole", + "E14001430": "Poplar and Limehouse", + "E14001431": "Portsmouth North", + "E14001432": "Portsmouth South", + "E14001433": "Preston", + "E14001434": "Putney", + "E14001435": "Queen's Park and Maida Vale", + "E14001436": "Rawmarsh and Conisbrough", + "E14001437": "Rayleigh and Wickford", + "E14001438": "Reading Central", + "E14001439": "Reading West and Mid Berkshire", + "E14001440": "Redcar", + "E14001441": "Redditch", + "E14001442": "Reigate", + "E14001443": "Ribble Valley", + "E14001444": "Richmond and Northallerton", + "E14001445": "Richmond Park", + "E14001446": "Rochdale", + "E14001447": "Rochester and Strood", + "E14001448": "Romford", + "E14001449": "Romsey and Southampton North", + "E14001450": "Rossendale and Darwen", + "E14001451": "Rother Valley", + "E14001452": "Rotherham", + "E14001453": "Rugby", + "E14001454": "Ruislip, Northwood and Pinner", + "E14001455": "Runcorn and Helsby", + "E14001456": "Runnymede and Weybridge", + "E14001457": "Rushcliffe", + "E14001458": "Rutland and Stamford", + "E14001459": "Salford", + "E14001460": "Salisbury", + "E14001461": "Scarborough and Whitby", + "E14001462": "Scunthorpe", + "E14001463": "Sefton Central", + "E14001464": "Selby", + "E14001465": "Sevenoaks", + "E14001466": "Sheffield Brightside and Hillsborough", + "E14001467": "Sheffield Central", + "E14001468": "Sheffield Hallam", + "E14001469": "Sheffield Heeley", + "E14001470": "Sheffield South East", + "E14001471": "Sherwood Forest", + "E14001472": "Shipley", + "E14001473": "Shrewsbury", + "E14001474": "Sittingbourne and Sheppey", + "E14001475": "Skipton and Ripon", + "E14001476": "Sleaford and North Hykeham", + "E14001477": "Slough", + "E14001478": "Smethwick", + "E14001479": "Solihull West and Shirley", + "E14001480": "South Basildon and East Thurrock", + "E14001481": "South Cambridgeshire", + "E14001482": "South Cotswolds", + "E14001483": "South Derbyshire", + "E14001484": "South Devon", + "E14001485": "South Dorset", + "E14001486": "South East Cornwall", + "E14001487": "South Holland and The Deepings", + "E14001488": "South Leicestershire", + "E14001489": "South Norfolk", + "E14001490": "South Northamptonshire", + "E14001491": "South Ribble", + "E14001492": "South Shields", + "E14001493": "South Shropshire", + "E14001494": "South Suffolk", + "E14001495": "South West Devon", + "E14001496": "South West Hertfordshire", + "E14001497": "South West Norfolk", + "E14001498": "South West Wiltshire", + "E14001499": "Southampton Itchen", + "E14001500": "Southampton Test", + "E14001501": "Southend East and Rochford", + "E14001502": "Southend West and Leigh", + "E14001503": "Southgate and Wood Green", + "E14001504": "Southport", + "E14001505": "Spelthorne", + "E14001506": "Spen Valley", + "E14001507": "St Albans", + "E14001508": "St Austell and Newquay", + "E14001509": "St Helens North", + "E14001510": "St Helens South and Whiston", + "E14001511": "St Ives", + "E14001512": "St Neots and Mid Cambridgeshire", + "E14001513": "Stafford", + "E14001514": "Staffordshire Moorlands", + "E14001515": "Stalybridge and Hyde", + "E14001516": "Stevenage", + "E14001517": "Stockport", + "E14001518": "Stockton North", + "E14001519": "Stockton West", + "E14001520": "Stoke-on-Trent Central", + "E14001521": "Stoke-on-Trent North", + "E14001522": "Stoke-on-Trent South", + "E14001523": "Stone, Great Wyrley and Penkridge", + "E14001524": "Stourbridge", + "E14001525": "Stratford and Bow", + "E14001526": "Stratford-on-Avon", + "E14001527": "Streatham and Croydon North", + "E14001528": "Stretford and Urmston", + "E14001529": "Stroud", + "E14001530": "Suffolk Coastal", + "E14001531": "Sunderland Central", + "E14001532": "Surrey Heath", + "E14001533": "Sussex Weald", + "E14001534": "Sutton and Cheam", + "E14001535": "Sutton Coldfield", + "E14001536": "Swindon North", + "E14001537": "Swindon South", + "E14001538": "Tamworth", + "E14001539": "Tatton", + "E14001540": "Taunton and Wellington", + "E14001541": "Telford", + "E14001542": "Tewkesbury", + "E14001543": "The Wrekin", + "E14001544": "Thirsk and Malton", + "E14001545": "Thornbury and Yate", + "E14001546": "Thurrock", + "E14001547": "Tipton and Wednesbury", + "E14001548": "Tiverton and Minehead", + "E14001549": "Tonbridge", + "E14001550": "Tooting", + "E14001551": "Torbay", + "E14001552": "Torridge and Tavistock", + "E14001553": "Tottenham", + "E14001554": "Truro and Falmouth", + "E14001555": "Tunbridge Wells", + "E14001556": "Twickenham", + "E14001557": "Tynemouth", + "E14001558": "Uxbridge and South Ruislip", + "E14001559": "Vauxhall and Camberwell Green", + "E14001560": "Wakefield and Rothwell", + "E14001561": "Wallasey", + "E14001562": "Walsall and Bloxwich", + "E14001563": "Walthamstow", + "E14001564": "Warrington North", + "E14001565": "Warrington South", + "E14001566": "Warwick and Leamington", + "E14001567": "Washington and Gateshead South", + "E14001568": "Watford", + "E14001569": "Waveney Valley", + "E14001570": "Weald of Kent", + "E14001571": "Wellingborough and Rushden", + "E14001572": "Wells and Mendip Hills", + "E14001573": "Welwyn Hatfield", + "E14001574": "West Bromwich", + "E14001575": "West Dorset", + "E14001576": "West Ham and Beckton", + "E14001577": "West Lancashire", + "E14001578": "West Suffolk", + "E14001579": "West Worcestershire", + "E14001580": "Westmorland and Lonsdale", + "E14001581": "Weston-super-Mare", + "E14001582": "Wetherby and Easingwold", + "E14001583": "Whitehaven and Workington", + "E14001584": "Widnes and Halewood", + "E14001585": "Wigan", + "E14001586": "Wimbledon", + "E14001587": "Winchester", + "E14001588": "Windsor", + "E14001589": "Wirral West", + "E14001590": "Witham", + "E14001591": "Witney", + "E14001592": "Woking", + "E14001593": "Wokingham", + "E14001594": "Wolverhampton North East", + "E14001595": "Wolverhampton South East", + "E14001596": "Wolverhampton West", + "E14001597": "Worcester", + "E14001598": "Worsley and Eccles", + "E14001599": "Worthing West", + "E14001600": "Wycombe", + "E14001601": "Wyre Forest", + "E14001602": "Wythenshawe and Sale East", + "E14001603": "Yeovil", + "E14001604": "York Central", + "E14001605": "York Outer", + "N05000001": "Belfast East", + "N05000002": "Belfast North", + "N05000003": "Belfast South and Mid Down", + "N05000004": "Belfast West", + "N05000005": "East Antrim", + "N05000006": "East Londonderry", + "N05000007": "Fermanagh and South Tyrone", + "N05000008": "Foyle", + "N05000009": "Lagan Valley", + "N05000010": "Mid Ulster", + "N05000011": "Newry and Armagh", + "N05000012": "North Antrim", + "N05000013": "North Down", + "N05000014": "South Antrim", + "N05000015": "South Down", + "N05000016": "Strangford", + "N05000017": "Upper Bann", + "N05000018": "West Tyrone", + "S14000021": "East Renfrewshire", + "S14000027": "Na h-Eileanan an Iar", + "S14000045": "Midlothian", + "S14000048": "North Ayrshire and Arran", + "S14000051": "Orkney and Shetland", + "S14000060": "Aberdeen North", + "S14000061": "Aberdeen South", + "S14000062": "Aberdeenshire North and Moray East", + "S14000063": "Airdrie and Shotts", + "S14000064": "Alloa and Grangemouth", + "S14000065": "Angus and Perthshire Glens", + "S14000066": "Arbroath and Broughty Ferry", + "S14000067": "Argyll, Bute and South Lochaber", + "S14000068": "Bathgate and Linlithgow", + "S14000069": "Caithness, Sutherland and Easter Ross", + "S14000070": "Coatbridge and Bellshill", + "S14000071": "Cowdenbeath and Kirkcaldy", + "S14000072": "Cumbernauld and Kirkintilloch", + "S14000073": "Dumfries and Galloway", + "S14000074": "Dumfriesshire, Clydesdale and Tweeddale", + "S14000075": "Dundee Central", + "S14000076": "Dunfermline and Dollar", + "S14000077": "East Kilbride and Strathaven", + "S14000078": "Edinburgh East and Musselburgh", + "S14000079": "Edinburgh North and Leith", + "S14000080": "Edinburgh South", + "S14000081": "Edinburgh South West", + "S14000082": "Edinburgh West", + "S14000083": "Falkirk", + "S14000084": "Glasgow East", + "S14000085": "Glasgow North", + "S14000086": "Glasgow North East", + "S14000087": "Glasgow South", + "S14000088": "Glasgow South West", + "S14000089": "Glasgow West", + "S14000090": "Glenrothes and Mid Fife", + "S14000091": "Gordon and Buchan", + "S14000092": "Hamilton and Clyde Valley", + "S14000093": "Inverclyde and Renfrewshire West", + "S14000094": "Inverness, Skye and West Ross-shire", + "S14000095": "Livingston", + "S14000096": "Lothian East", + "S14000097": "Mid Dunbartonshire", + "S14000098": "Moray West, Nairn and Strathspey", + "S14000099": "Motherwell, Wishaw and Carluke", + "S14000100": "North East Fife", + "S14000101": "Paisley and Renfrewshire North", + "S14000102": "Paisley and Renfrewshire South", + "S14000103": "Perth and Kinross-shire", + "S14000104": "Rutherglen", + "S14000105": "Stirling and Strathallan", + "S14000106": "West Dunbartonshire", + "S14000107": "Ayr, Carrick and Cumnock", + "S14000108": "Berwickshire, Roxburgh and Selkirk", + "S14000109": "Central Ayrshire", + "S14000110": "Kilmarnock and Loudoun", + "S14000111": "West Aberdeenshire and Kincardine", + "W07000081": "Aberafan Maesteg", + "W07000082": "Alyn and Deeside", + "W07000083": "Bangor Aberconwy", + "W07000084": "Blaenau Gwent and Rhymney", + "W07000085": "Brecon, Radnor and Cwm Tawe", + "W07000086": "Bridgend", + "W07000087": "Caerfyrddin", + "W07000088": "Caerphilly", + "W07000089": "Cardiff East", + "W07000090": "Cardiff North", + "W07000091": "Cardiff South and Penarth", + "W07000092": "Cardiff West", + "W07000093": "Ceredigion Preseli", + "W07000094": "Clwyd East", + "W07000095": "Clwyd North", + "W07000096": "Dwyfor Meirionnydd", + "W07000097": "Gower", + "W07000098": "Llanelli", + "W07000099": "Merthyr Tydfil and Aberdare", + "W07000100": "Mid and South Pembrokeshire", + "W07000101": "Monmouthshire", + "W07000102": "Montgomeryshire and Glyndwr", + "W07000103": "Neath and Swansea East", + "W07000104": "Newport East", + "W07000105": "Newport West and Islwyn", + "W07000106": "Pontypridd", + "W07000107": "Rhondda and Ogmore", + "W07000108": "Swansea West", + "W07000109": "Torfaen", + "W07000110": "Vale of Glamorgan", + "W07000111": "Wrexham", + "W07000112": "Ynys Môn" +} diff --git a/app/src/data/ukHexLayouts/local_authority_names_2023.json b/app/src/data/ukHexLayouts/local_authority_names_2023.json new file mode 100644 index 000000000..6f03038bd --- /dev/null +++ b/app/src/data/ukHexLayouts/local_authority_names_2023.json @@ -0,0 +1,363 @@ +{ + "E06000001": "Hartlepool", + "E06000002": "Middlesbrough", + "E06000003": "Redcar and Cleveland", + "E06000004": "Stockton-on-Tees", + "E06000005": "Darlington", + "E06000006": "Halton", + "E06000007": "Warrington", + "E06000008": "Blackburn with Darwen", + "E06000009": "Blackpool", + "E06000010": "Kingston upon Hull, City of", + "E06000011": "East Riding of Yorkshire", + "E06000012": "North East Lincolnshire", + "E06000013": "North Lincolnshire", + "E06000014": "York", + "E06000015": "Derby", + "E06000016": "Leicester", + "E06000017": "Rutland", + "E06000018": "Nottingham", + "E06000019": "Herefordshire, County of", + "E06000020": "Telford and Wrekin", + "E06000021": "Stoke-on-Trent", + "E06000022": "Bath and North East Somerset", + "E06000023": "Bristol, City of", + "E06000024": "North Somerset", + "E06000025": "South Gloucestershire", + "E06000026": "Plymouth", + "E06000027": "Torbay", + "E06000030": "Swindon", + "E06000031": "Peterborough", + "E06000032": "Luton", + "E06000033": "Southend-on-Sea", + "E06000034": "Thurrock", + "E06000035": "Medway", + "E06000036": "Bracknell Forest", + "E06000037": "West Berkshire", + "E06000038": "Reading", + "E06000039": "Slough", + "E06000040": "Windsor and Maidenhead", + "E06000041": "Wokingham", + "E06000042": "Milton Keynes", + "E06000043": "Brighton and Hove", + "E06000044": "Portsmouth", + "E06000045": "Southampton", + "E06000046": "Isle of Wight", + "E06000047": "County Durham", + "E06000049": "Cheshire East", + "E06000050": "Cheshire West and Chester", + "E06000051": "Shropshire", + "E06000052": "Cornwall", + "E06000053": "Isles of Scilly", + "E06000054": "Wiltshire", + "E06000055": "Bedford", + "E06000056": "Central Bedfordshire", + "E06000057": "Northumberland", + "E06000058": "Bournemouth, Christchurch and Poole", + "E06000059": "Dorset", + "E06000060": "Buckinghamshire", + "E06000061": "North Northamptonshire", + "E06000062": "West Northamptonshire", + "E06000063": "Cumberland", + "E06000064": "Westmorland and Furness", + "E06000065": "North Yorkshire", + "E06000066": "Somerset", + "E07000008": "Cambridge", + "E07000009": "East Cambridgeshire", + "E07000010": "Fenland", + "E07000011": "Huntingdonshire", + "E07000012": "South Cambridgeshire", + "E07000032": "Amber Valley", + "E07000033": "Bolsover", + "E07000034": "Chesterfield", + "E07000035": "Derbyshire Dales", + "E07000036": "Erewash", + "E07000037": "High Peak", + "E07000038": "North East Derbyshire", + "E07000039": "South Derbyshire", + "E07000040": "East Devon", + "E07000041": "Exeter", + "E07000042": "Mid Devon", + "E07000043": "North Devon", + "E07000044": "South Hams", + "E07000045": "Teignbridge", + "E07000046": "Torridge", + "E07000047": "West Devon", + "E07000061": "Eastbourne", + "E07000062": "Hastings", + "E07000063": "Lewes", + "E07000064": "Rother", + "E07000065": "Wealden", + "E07000066": "Basildon", + "E07000067": "Braintree", + "E07000068": "Brentwood", + "E07000069": "Castle Point", + "E07000070": "Chelmsford", + "E07000071": "Colchester", + "E07000072": "Epping Forest", + "E07000073": "Harlow", + "E07000074": "Maldon", + "E07000075": "Rochford", + "E07000076": "Tendring", + "E07000077": "Uttlesford", + "E07000078": "Cheltenham", + "E07000079": "Cotswold", + "E07000080": "Forest of Dean", + "E07000081": "Gloucester", + "E07000082": "Stroud", + "E07000083": "Tewkesbury", + "E07000084": "Basingstoke and Deane", + "E07000085": "East Hampshire", + "E07000086": "Eastleigh", + "E07000087": "Fareham", + "E07000088": "Gosport", + "E07000089": "Hart", + "E07000090": "Havant", + "E07000091": "New Forest", + "E07000092": "Rushmoor", + "E07000093": "Test Valley", + "E07000094": "Winchester", + "E07000095": "Broxbourne", + "E07000096": "Dacorum", + "E07000098": "Hertsmere", + "E07000099": "North Hertfordshire", + "E07000102": "Three Rivers", + "E07000103": "Watford", + "E07000105": "Ashford", + "E07000106": "Canterbury", + "E07000107": "Dartford", + "E07000108": "Dover", + "E07000109": "Gravesham", + "E07000110": "Maidstone", + "E07000111": "Sevenoaks", + "E07000112": "Folkestone and Hythe", + "E07000113": "Swale", + "E07000114": "Thanet", + "E07000115": "Tonbridge and Malling", + "E07000116": "Tunbridge Wells", + "E07000117": "Burnley", + "E07000118": "Chorley", + "E07000119": "Fylde", + "E07000120": "Hyndburn", + "E07000121": "Lancaster", + "E07000122": "Pendle", + "E07000123": "Preston", + "E07000124": "Ribble Valley", + "E07000125": "Rossendale", + "E07000126": "South Ribble", + "E07000127": "West Lancashire", + "E07000128": "Wyre", + "E07000129": "Blaby", + "E07000130": "Charnwood", + "E07000131": "Harborough", + "E07000132": "Hinckley and Bosworth", + "E07000133": "Melton", + "E07000134": "North West Leicestershire", + "E07000135": "Oadby and Wigston", + "E07000136": "Boston", + "E07000137": "East Lindsey", + "E07000138": "Lincoln", + "E07000139": "North Kesteven", + "E07000140": "South Holland", + "E07000141": "South Kesteven", + "E07000142": "West Lindsey", + "E07000143": "Breckland", + "E07000144": "Broadland", + "E07000145": "Great Yarmouth", + "E07000146": "King's Lynn and West Norfolk", + "E07000147": "North Norfolk", + "E07000148": "Norwich", + "E07000149": "South Norfolk", + "E07000170": "Ashfield", + "E07000171": "Bassetlaw", + "E07000172": "Broxtowe", + "E07000173": "Gedling", + "E07000174": "Mansfield", + "E07000175": "Newark and Sherwood", + "E07000176": "Rushcliffe", + "E07000177": "Cherwell", + "E07000178": "Oxford", + "E07000179": "South Oxfordshire", + "E07000180": "Vale of White Horse", + "E07000181": "West Oxfordshire", + "E07000192": "Cannock Chase", + "E07000193": "East Staffordshire", + "E07000194": "Lichfield", + "E07000195": "Newcastle-under-Lyme", + "E07000196": "South Staffordshire", + "E07000197": "Stafford", + "E07000198": "Staffordshire Moorlands", + "E07000199": "Tamworth", + "E07000200": "Babergh", + "E07000202": "Ipswich", + "E07000203": "Mid Suffolk", + "E07000207": "Elmbridge", + "E07000208": "Epsom and Ewell", + "E07000209": "Guildford", + "E07000210": "Mole Valley", + "E07000211": "Reigate and Banstead", + "E07000212": "Runnymede", + "E07000213": "Spelthorne", + "E07000214": "Surrey Heath", + "E07000215": "Tandridge", + "E07000216": "Waverley", + "E07000217": "Woking", + "E07000218": "North Warwickshire", + "E07000219": "Nuneaton and Bedworth", + "E07000220": "Rugby", + "E07000221": "Stratford-on-Avon", + "E07000222": "Warwick", + "E07000223": "Adur", + "E07000224": "Arun", + "E07000225": "Chichester", + "E07000226": "Crawley", + "E07000227": "Horsham", + "E07000228": "Mid Sussex", + "E07000229": "Worthing", + "E07000234": "Bromsgrove", + "E07000235": "Malvern Hills", + "E07000236": "Redditch", + "E07000237": "Worcester", + "E07000238": "Wychavon", + "E07000239": "Wyre Forest", + "E07000240": "St Albans", + "E07000241": "Welwyn Hatfield", + "E07000242": "East Hertfordshire", + "E07000243": "Stevenage", + "E07000244": "East Suffolk", + "E07000245": "West Suffolk", + "E08000001": "Bolton", + "E08000002": "Bury", + "E08000003": "Manchester", + "E08000004": "Oldham", + "E08000005": "Rochdale", + "E08000006": "Salford", + "E08000007": "Stockport", + "E08000008": "Tameside", + "E08000009": "Trafford", + "E08000010": "Wigan", + "E08000011": "Knowsley", + "E08000012": "Liverpool", + "E08000013": "St. Helens", + "E08000014": "Sefton", + "E08000015": "Wirral", + "E08000016": "Barnsley", + "E08000017": "Doncaster", + "E08000018": "Rotherham", + "E08000019": "Sheffield", + "E08000021": "Newcastle upon Tyne", + "E08000022": "North Tyneside", + "E08000023": "South Tyneside", + "E08000024": "Sunderland", + "E08000025": "Birmingham", + "E08000026": "Coventry", + "E08000027": "Dudley", + "E08000028": "Sandwell", + "E08000029": "Solihull", + "E08000030": "Walsall", + "E08000031": "Wolverhampton", + "E08000032": "Bradford", + "E08000033": "Calderdale", + "E08000034": "Kirklees", + "E08000035": "Leeds", + "E08000036": "Wakefield", + "E08000037": "Gateshead", + "E09000001": "City of London", + "E09000002": "Barking and Dagenham", + "E09000003": "Barnet", + "E09000004": "Bexley", + "E09000005": "Brent", + "E09000006": "Bromley", + "E09000007": "Camden", + "E09000008": "Croydon", + "E09000009": "Ealing", + "E09000010": "Enfield", + "E09000011": "Greenwich", + "E09000012": "Hackney", + "E09000013": "Hammersmith and Fulham", + "E09000014": "Haringey", + "E09000015": "Harrow", + "E09000016": "Havering", + "E09000017": "Hillingdon", + "E09000018": "Hounslow", + "E09000019": "Islington", + "E09000020": "Kensington and Chelsea", + "E09000021": "Kingston upon Thames", + "E09000022": "Lambeth", + "E09000023": "Lewisham", + "E09000024": "Merton", + "E09000025": "Newham", + "E09000026": "Redbridge", + "E09000027": "Richmond upon Thames", + "E09000028": "Southwark", + "E09000029": "Sutton", + "E09000030": "Tower Hamlets", + "E09000031": "Waltham Forest", + "E09000032": "Wandsworth", + "E09000033": "Westminster", + "N09000001": "Antrim and Newtownabbey", + "N09000002": "Armagh City, Banbridge and Craigavon", + "N09000003": "Belfast", + "N09000004": "Causeway Coast and Glens", + "N09000005": "Derry City and Strabane", + "N09000006": "Fermanagh and Omagh", + "N09000007": "Lisburn and Castlereagh", + "N09000008": "Mid and East Antrim", + "N09000009": "Mid Ulster", + "N09000010": "Newry, Mourne and Down", + "N09000011": "Ards and North Down", + "S12000005": "Clackmannanshire", + "S12000006": "Dumfries and Galloway", + "S12000008": "East Ayrshire", + "S12000010": "East Lothian", + "S12000011": "East Renfrewshire", + "S12000013": "Na h-Eileanan Siar", + "S12000014": "Falkirk", + "S12000017": "Highland", + "S12000018": "Inverclyde", + "S12000019": "Midlothian", + "S12000020": "Moray", + "S12000021": "North Ayrshire", + "S12000023": "Orkney Islands", + "S12000026": "Scottish Borders", + "S12000027": "Shetland Islands", + "S12000028": "South Ayrshire", + "S12000029": "South Lanarkshire", + "S12000030": "Stirling", + "S12000033": "Aberdeen City", + "S12000034": "Aberdeenshire", + "S12000035": "Argyll and Bute", + "S12000036": "City of Edinburgh", + "S12000038": "Renfrewshire", + "S12000039": "West Dunbartonshire", + "S12000040": "West Lothian", + "S12000041": "Angus", + "S12000042": "Dundee City", + "S12000045": "East Dunbartonshire", + "S12000047": "Fife", + "S12000048": "Perth and Kinross", + "S12000049": "Glasgow City", + "S12000050": "North Lanarkshire", + "W06000001": "Isle of Anglesey", + "W06000002": "Gwynedd", + "W06000003": "Conwy", + "W06000004": "Denbighshire", + "W06000005": "Flintshire", + "W06000006": "Wrexham", + "W06000008": "Ceredigion", + "W06000009": "Pembrokeshire", + "W06000010": "Carmarthenshire", + "W06000011": "Swansea", + "W06000012": "Neath Port Talbot", + "W06000013": "Bridgend", + "W06000014": "Vale of Glamorgan", + "W06000015": "Cardiff", + "W06000016": "Rhondda Cynon Taf", + "W06000018": "Caerphilly", + "W06000019": "Blaenau Gwent", + "W06000020": "Torfaen", + "W06000021": "Monmouthshire", + "W06000022": "Newport", + "W06000023": "Powys", + "W06000024": "Merthyr Tydfil" +} diff --git a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts index a5627a22d..277a0fa90 100644 --- a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts +++ b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts @@ -64,3 +64,28 @@ export const ALLERDALE_POINT: HexMapDataPoint = { y: 19, code: ALLERDALE_CODE, }; + +/** Report regions: the UK, a nation, and one constituency, which has no local-area maps. */ +export const UK_WIDE_REGION = 'uk'; +export const WALES_REGION = 'country/wales'; +export const CONSTITUENCY_REGION = 'constituency/Aldershot'; + +/** Wales's constituencies in the layout, by code prefix. */ +export const WELSH_CONSTITUENCY_COUNT = 32; + +/** Cardiff East, a Welsh constituency, and its name in the boundary file. */ +export const CARDIFF_EAST_CODE = 'W07000089'; +export const CARDIFF_EAST_NAME = 'Cardiff East'; + +/** Hartlepool's name in the local authority boundary file. */ +export const HARTLEPOOL_NAME = 'Hartlepool'; + +/** + * Three of Wales's 32 constituencies' results, as the maps' adapters give + * them, with the Public API's positions. + */ +export const WELSH_CONSTITUENCY_POINTS: HexMapDataPoint[] = [ + { id: 'Clwyd East', label: 'Clwyd East', value: -355.03, x: 49, y: -30, code: 'W07000094' }, + { id: 'Monmouthshire', label: 'Monmouthshire', value: -90.78, x: 50, y: -36, code: 'W07000101' }, + { id: 'Gower', label: 'Gower', value: 237.22, x: 44, y: -37, code: 'W07000097' }, +]; diff --git a/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts index 58eec3ddf..cd08b2742 100644 --- a/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts +++ b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts @@ -3,6 +3,7 @@ import { placeOnUKHexLayout, UK_HEX_LAYOUTS, ukHexPosition, + withheldUKHexAreas, } from '@/components/visualization/choropleth/ukHexLayout'; import { ALLERDALE_CODE, @@ -10,12 +11,24 @@ import { BARNSLEY_NORTH_CODE, BARNSLEY_NORTH_HEX, BARNSLEY_NORTH_POINT, + CARDIFF_EAST_CODE, + CARDIFF_EAST_NAME, + CONSTITUENCY_REGION, DARLINGTON_POINT, HARTLEPOOL_CODE, HARTLEPOOL_HEX, + HARTLEPOOL_NAME, HARTLEPOOL_POINT, + UK_WIDE_REGION, UNCODED_POINT, + WALES_REGION, + WELSH_CONSTITUENCY_COUNT, + WELSH_CONSTITUENCY_POINTS, } from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; +import { + EXPECTED_CONSTITUENCY_COUNT, + EXPECTED_LOCAL_AUTHORITY_COUNT, +} from '@/tests/fixtures/data/uk_geography/ukGeographyReference'; describe('ukHexPosition', () => { test('given a constituency code then it gives the layout column and the row moved up by one', () => { @@ -141,3 +154,95 @@ describe('placeOnUKHexLayout', () => { expect(placement.tiled).toBe(true); }); }); + +describe('withheldUKHexAreas', () => { + test("given a Wales report with three constituencies then it lists Wales's other constituencies", () => { + // When + const areas = withheldUKHexAreas('constituency', WELSH_CONSTITUENCY_POINTS, WALES_REGION); + + // Then + const reportedCodes = WELSH_CONSTITUENCY_POINTS.map((point) => point.code); + expect(areas).toHaveLength(WELSH_CONSTITUENCY_COUNT - WELSH_CONSTITUENCY_POINTS.length); + expect(areas.every((area) => area.id.startsWith('W'))).toBe(true); + expect(areas.some((area) => reportedCodes.includes(area.id))).toBe(false); + }); + + test('given a withheld constituency then it is at its layout position, named from the boundary file', () => { + // When + const areas = withheldUKHexAreas('constituency', WELSH_CONSTITUENCY_POINTS, WALES_REGION); + + // Then + expect(areas.find((area) => area.id === CARDIFF_EAST_CODE)).toEqual({ + id: CARDIFF_EAST_CODE, + label: CARDIFF_EAST_NAME, + ...ukHexPosition('constituency', CARDIFF_EAST_CODE), + }); + }); + + test.each([ + ['a UK-wide report', UK_WIDE_REGION], + ['no region', undefined], + ])('given %s then it lists every constituency the result leaves out', (_, region) => { + // When + const areas = withheldUKHexAreas('constituency', [BARNSLEY_NORTH_POINT], region); + + // Then + expect(areas).toHaveLength(EXPECTED_CONSTITUENCY_COUNT - 1); + expect(areas.some((area) => area.id === BARNSLEY_NORTH_CODE)).toBe(false); + }); + + test('given local authorities then it reads the local authority layout and names', () => { + // When + const areas = withheldUKHexAreas('local_authority', [DARLINGTON_POINT], UK_WIDE_REGION); + + // Then + expect(areas).toHaveLength(EXPECTED_LOCAL_AUTHORITY_COUNT - 1); + expect(areas.find((area) => area.id === HARTLEPOOL_CODE)).toEqual({ + id: HARTLEPOOL_CODE, + label: HARTLEPOOL_NAME, + x: HARTLEPOOL_HEX[0], + y: HARTLEPOOL_HEX[1] + 1, + }); + }); + + test("given every one of a nation's areas reported then it lists none", () => { + // Given + const everyWelshConstituency = Object.keys(UK_HEX_LAYOUTS.constituency) + .filter((code) => code.startsWith('W')) + .map((code) => ({ id: code, label: code, value: 1, code })); + + // When + const areas = withheldUKHexAreas('constituency', everyWelshConstituency, WALES_REGION); + + // Then + expect(areas).toEqual([]); + }); + + test.each([ + ['an area without a code', [BARNSLEY_NORTH_POINT, UNCODED_POINT], UK_WIDE_REGION], + ['a code the layout lacks', [HARTLEPOOL_POINT], UK_WIDE_REGION], + ["an area outside the report's nation", [BARNSLEY_NORTH_POINT], WALES_REGION], + ['a region that is neither the UK nor a nation', [BARNSLEY_NORTH_POINT], CONSTITUENCY_REGION], + ])( + 'given %s then the result cannot be matched to the layout, and it lists none', + (_, points, region) => { + // When + const areas = withheldUKHexAreas('constituency', points, region); + + // Then + expect(areas).toEqual([]); + } + ); + + test('given a local authority on a pre-2023 code then it lists none', () => { + // When + const areas = withheldUKHexAreas( + 'local_authority', + [DARLINGTON_POINT, ALLERDALE_POINT], + UK_WIDE_REGION + ); + + // Then + expect(areas).toEqual([]); + }); +}); diff --git a/app/src/tests/unit/data/ukBoundaryFiles.test.ts b/app/src/tests/unit/data/ukBoundaryFiles.test.ts index c5a62621f..613fdec51 100644 --- a/app/src/tests/unit/data/ukBoundaryFiles.test.ts +++ b/app/src/tests/unit/data/ukBoundaryFiles.test.ts @@ -5,7 +5,10 @@ import { UK_AREA_COUNTS_BY_NATION, ukNationFromCode, } from '@/components/visualization/choropleth/ukAreaBoundaries'; -import { UK_HEX_LAYOUTS } from '@/components/visualization/choropleth/ukHexLayout'; +import { + UK_HEX_AREA_NAMES, + UK_HEX_LAYOUTS, +} from '@/components/visualization/choropleth/ukHexLayout'; import { BOUNDARY_FILE_DIRECTORY, CONSTITUENCY_BOUNDARY_FILE, @@ -94,6 +97,22 @@ describe.each(LEVELS)( expect(new Set(positions).size).toBe(expectedCount); }); + test("given the hex map's area names then each of the file's codes has its boundary's name", () => { + // Given + const boundaryNames = Object.fromEntries( + readBoundaries(boundaryFile).map((feature) => [ + feature.properties[codeProperty], + feature.properties[nameProperty], + ]) + ); + + // When + const names = UK_HEX_AREA_NAMES[boundaryLevel]; + + // Then + expect(names).toEqual(boundaryNames); + }); + test(`given .py 6.10.0's list then the file holds exactly its ${expectedCount} codes, once each`, () => { // Given const referenceCodes = readPyAreaList(referenceFile).map((area) => area.code); From b3f8cd4be014c1ed9e3a221ada7ec637688b284e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:02:59 +0400 Subject: [PATCH 10/21] Draw UK areas disclosure control withheld on the hex maps For a level from a local-area breakdown, the v3 and v2 constituency and local authority hex maps draw every area of the report's region: all 650 constituencies or 361 local authorities for a UK-wide report, and the nation's for a nation's report. An area the result leaves out is drawn hatched in its place, outside the colour scale, and its tooltip reads its name and "Not reported". It has no value, so it is not in the colour range or v3's rankings, and it cannot be selected. A saved level, a result with an area that has no code, or one with a code outside the region's layout still draws only its own areas, and the note on areas the layout cannot place stays separate from the note on withheld areas. Adapted for main, where a result the layout cannot fully place keeps its own x and y, untiled: the hex maps draw withheld areas only when placeOnUKHexLayout tiled the map (hexMap.tiled), and pass hexMap.points as before. v3's test of a breakdown area without a code checks that the map stays untiled with no withheld areas, instead of the layout note, which main never had; the fixture for that output is added here. (cherry picked from commit e3ecff7a999ed9e053fedcf4cf25138b3bdec239) Co-Authored-By: Claude Opus 5.5 --- .../adapters/ukLocalAreaBreakdownAdapter.ts | 6 + .../flagship/report/UKLocalAreaMaps.tsx | 18 +- app/src/data/ukHexLayouts/README.md | 2 +- .../AbsoluteChangeByConstituency.tsx | 24 ++- .../RelativeChangeByConstituency.tsx | 24 ++- .../AbsoluteChangeByLocalAuthority.tsx | 24 ++- .../RelativeChangeByLocalAuthority.tsx | 24 ++- .../flagship/report/ukLocalAreaMapsMocks.ts | 18 ++ .../choropleth/ukHexLayoutMocks.ts | 7 + .../flagship/report/UKLocalAreaMaps.test.tsx | 202 ++++++++++++++++++ .../ConstituencySubPage.test.tsx | 9 +- .../LocalAuthoritySubPage.test.tsx | 9 +- .../AbsoluteChangeByConstituency.test.tsx | 73 +++++++ .../RelativeChangeByConstituency.test.tsx | 73 +++++++ .../AbsoluteChangeByLocalAuthority.test.tsx | 72 +++++++ .../RelativeChangeByLocalAuthority.test.tsx | 78 ++++++- 16 files changed, 643 insertions(+), 20 deletions(-) diff --git a/app/src/adapters/ukLocalAreaBreakdownAdapter.ts b/app/src/adapters/ukLocalAreaBreakdownAdapter.ts index 6455abf7b..fb74d8393 100644 --- a/app/src/adapters/ukLocalAreaBreakdownAdapter.ts +++ b/app/src/adapters/ukLocalAreaBreakdownAdapter.ts @@ -126,6 +126,12 @@ export const UK_AREA_NOUNS: Record = local_authority: { one: 'local authority', many: 'local authorities' }, }; +/** + * What an area disclosure control withheld reads on the maps, in its + * tooltip: the geographic map's boundary and the hex map's hexagon alike. + */ +export const UK_WITHHELD_AREA_TEXT = 'Not reported'; + /** * How many of a level's areas a disclosure-controlled breakdown left out, * out of how many there are; null when the app cannot count them. diff --git a/app/src/components/flagship/report/UKLocalAreaMaps.tsx b/app/src/components/flagship/report/UKLocalAreaMaps.tsx index 24c9bbfbd..ef2089b95 100644 --- a/app/src/components/flagship/report/UKLocalAreaMaps.tsx +++ b/app/src/components/flagship/report/UKLocalAreaMaps.tsx @@ -3,6 +3,7 @@ import { IconChevronDown, IconSearch } from '@tabler/icons-react'; import { areaEstimateFields } from '@/adapters/ukAreaEstimateFields'; import { UK_AREA_NOUNS, + UK_WITHHELD_AREA_TEXT, type UKAreaLevel, type UKBreakdownWithheldAreas, } from '@/adapters/ukLocalAreaBreakdownAdapter'; @@ -38,7 +39,10 @@ import { ukNationFromRegion, } from '@/components/visualization/choropleth/ukAreaBoundaries'; import { UKAreaChoroplethMap } from '@/components/visualization/choropleth/UKAreaChoroplethMap'; -import { placeOnUKHexLayout } from '@/components/visualization/choropleth/ukHexLayout'; +import { + placeOnUKHexLayout, + withheldUKHexAreas, +} from '@/components/visualization/choropleth/ukHexLayout'; import { HexagonalMap } from '@/components/visualization/HexagonalMap'; import { colors, spacing, typography } from '@/designTokens'; import type { ReportOutputSocietyWideByConstituency } from '@/types/metadata/ReportOutputSocietyWideByConstituency'; @@ -268,6 +272,14 @@ export default function UKLocalAreaMaps({ // A result with any area the layout cannot place keeps all the API's // positions, on their square grid. const hexMap = useMemo(() => placeOnUKHexLayout(level, mapData), [level, mapData]); + // A level the breakdown supplied is disclosure-controlled: the areas of + // the region it leaves out are the ones withheld, which the hex map draws + // as not reported, on the tiled grid only. A saved level has none. + const levelWithheld = withheld?.[level] !== undefined; + const withheldHexAreas = useMemo( + () => (levelWithheld && hexMap.tiled ? withheldUKHexAreas(level, mapData, region) : []), + [levelWithheld, hexMap.tiled, level, mapData, region] + ); const decimals = useMemo(() => percentDecimals(mapData.map((point) => point.value)), [mapData]); const formatValue = useMemo( @@ -392,7 +404,7 @@ export default function UKLocalAreaMaps({ focusNation={ukNationFromRegion(region)} exportRef={mapRef} // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld ? 'Not reported' : undefined} + missingAreaText={withheld ? UK_WITHHELD_AREA_TEXT : undefined} navigable selectedId={selectedArea ? selectedName : null} onAreaClick={toggleSelected} @@ -401,6 +413,8 @@ export default function UKLocalAreaMaps({ ) : ( (areas ? transformConstituencyAbsoluteChange(areas) : []), [areas]); // The hex map places each constituency by its code in the app's layout, as v3's does. const hexMap = useMemo(() => placeOnUKHexLayout('constituency', mapData), [mapData]); + // The constituencies a breakdown leaves out are the ones disclosure control + // withheld: the hex map draws each in its place, as not reported, + // on the tiled grid only. + const withheldHexAreas = useMemo( + () => + withheld === undefined || !hexMap.tiled + ? [] + : withheldUKHexAreas('constituency', mapData, region), + [withheld, hexMap.tiled, mapData, region] + ); const notes = ; if (!mapData.length) { @@ -88,11 +104,13 @@ export function AbsoluteChangeByConstituency({ focusNation={ukNationFromRegion(region)} exportRef={mapRef} // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld === undefined ? undefined : 'Not reported'} + missingAreaText={withheld === undefined ? undefined : UK_WITHHELD_AREA_TEXT} /> ) : ( (areas ? transformConstituencyRelativeChange(areas) : []), [areas]); // The hex map places each constituency by its code in the app's layout, as v3's does. const hexMap = useMemo(() => placeOnUKHexLayout('constituency', mapData), [mapData]); + // The constituencies a breakdown leaves out are the ones disclosure control + // withheld: the hex map draws each in its place, as not reported, + // on the tiled grid only. + const withheldHexAreas = useMemo( + () => + withheld === undefined || !hexMap.tiled + ? [] + : withheldUKHexAreas('constituency', mapData, region), + [withheld, hexMap.tiled, mapData, region] + ); const notes = ; if (!mapData.length) { @@ -88,11 +104,13 @@ export function RelativeChangeByConstituency({ focusNation={ukNationFromRegion(region)} exportRef={mapRef} // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld === undefined ? undefined : 'Not reported'} + missingAreaText={withheld === undefined ? undefined : UK_WITHHELD_AREA_TEXT} /> ) : ( placeOnUKHexLayout('local_authority', mapData), [mapData]); + // The local authorities a breakdown leaves out are the ones disclosure + // control withheld: the hex map draws each in its place, as not reported, + // on the tiled grid only. + const withheldHexAreas = useMemo( + () => + withheld === undefined || !hexMap.tiled + ? [] + : withheldUKHexAreas('local_authority', mapData, region), + [withheld, hexMap.tiled, mapData, region] + ); const notes = ; if (!mapData.length) { @@ -91,11 +107,13 @@ export function AbsoluteChangeByLocalAuthority({ focusNation={ukNationFromRegion(region)} exportRef={mapRef} // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld === undefined ? undefined : 'Not reported'} + missingAreaText={withheld === undefined ? undefined : UK_WITHHELD_AREA_TEXT} /> ) : ( placeOnUKHexLayout('local_authority', mapData), [mapData]); + // The local authorities a breakdown leaves out are the ones disclosure + // control withheld: the hex map draws each in its place, as not reported, + // on the tiled grid only. + const withheldHexAreas = useMemo( + () => + withheld === undefined || !hexMap.tiled + ? [] + : withheldUKHexAreas('local_authority', mapData, region), + [withheld, hexMap.tiled, mapData, region] + ); const notes = ; if (!mapData.length) { @@ -91,11 +107,13 @@ export function RelativeChangeByLocalAuthority({ focusNation={ukNationFromRegion(region)} exportRef={mapRef} // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld === undefined ? undefined : 'Not reported'} + missingAreaText={withheld === undefined ? undefined : UK_WITHHELD_AREA_TEXT} /> ) : ( { + const output = UK_LOCAL_AREAS_OUTPUT as unknown as { + constituency_impact: { by_constituency: Record }; + }; + const { code: _gowerCode, ...gower } = output.constituency_impact.by_constituency.Gower; + return { + ...UK_LOCAL_AREAS_OUTPUT, + constituency_impact: { + by_constituency: { ...output.constituency_impact.by_constituency, Gower: gower }, + }, + } as unknown as SocietyWideReportOutput; +})(); diff --git a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts index 277a0fa90..dcaa3970a 100644 --- a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts +++ b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts @@ -89,3 +89,10 @@ export const WELSH_CONSTITUENCY_POINTS: HexMapDataPoint[] = [ { id: 'Monmouthshire', label: 'Monmouthshire', value: -90.78, x: 50, y: -36, code: 'W07000101' }, { id: 'Gower', label: 'Gower', value: 237.22, x: 44, y: -37, code: 'W07000097' }, ]; + +/** Wales's local authorities in the layout, by code prefix. */ +export const WELSH_LOCAL_AUTHORITY_COUNT = 22; + +/** Swansea, a Welsh local authority, and its name in the boundary file. */ +export const SWANSEA_CODE = 'W06000011'; +export const SWANSEA_NAME = 'Swansea'; diff --git a/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx b/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx index 70e9541e8..cf949f6e8 100644 --- a/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx +++ b/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx @@ -8,6 +8,7 @@ import UKLocalAreaMaps, { import { LIST_SHAPE_UK_RESULT } from '@/tests/fixtures/adapters/societyWideOutputNormalizerMocks'; import { UK_LOCAL_AREAS_OUTPUT, + UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED, UK_LOCAL_AREAS_OUTPUT_NO_CODES, UK_LOCAL_AREAS_OUTPUT_WITHOUT_API_POSITIONS, UK_WIDE_REGION, @@ -30,6 +31,17 @@ import { MONMOUTHSHIRE_CODE, NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; +import { + CARDIFF_EAST_NAME, + SWANSEA_CODE, + SWANSEA_NAME, + WELSH_CONSTITUENCY_COUNT, + WELSH_LOCAL_AUTHORITY_COUNT, +} from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; +import { + EXPECTED_CONSTITUENCY_COUNT, + EXPECTED_LOCAL_AUTHORITY_COUNT, +} from '@/tests/fixtures/data/uk_geography/ukGeographyReference'; import { NORMALIZED_BREAKDOWN } from '@/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks'; import { MOCK_UK_REPORT_OUTPUT_NO_LA_FIELD, @@ -48,6 +60,16 @@ function hexagon(container: HTMLElement, name: string): Element { return container.querySelector(`[data-area-id="${name}"]`)!; } +/** The hexagons of areas the result leaves out, which have no value. */ +function withheldHexagons(container: HTMLElement): Element[] { + return [...container.querySelectorAll('[data-missing-area-id]')]; +} + +/** A withheld area's hexagon, by its ONS code. */ +function withheldHexagon(container: HTMLElement, code: string): Element | null { + return container.querySelector(`[data-missing-area-id="${code}"]`); +} + /** An area's boundary on the geographic map, by its ONS code. */ function boundary(code: string): Promise { return screen.findByTestId(`area-${code}`); @@ -722,3 +744,183 @@ describe('UKLocalAreaMaps missing levels', () => { expect(screen.queryByText('Compute these areas')).not.toBeInTheDocument(); }); }); + +describe('UKLocalAreaMaps withheld areas on the hex map', () => { + const breakdownWithheld = { + constituency: SOME_WITHHELD_CONSTITUENCIES, + local_authority: SOME_WITHHELD_LOCAL_AUTHORITIES, + }; + const breakdownOutput = { + ...NATIONAL_OUTPUT_WITHOUT_AREAS, + constituency_impact: NORMALIZED_BREAKDOWN.constituency_impact, + local_authority_impact: NORMALIZED_BREAKDOWN.local_authority_impact, + } as typeof NATIONAL_OUTPUT_WITHOUT_AREAS; + + /** v3's local-area maps of `output`, with the hex grid chosen. */ + async function renderHexMap(props: Parameters[0]) { + const user = userEvent.setup(); + const view = render(, 'uk'); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await showHexMap(user); + return { ...view, user }; + } + + test('given a Wales breakdown then the hex map draws every Welsh constituency, the withheld ones without a value', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: breakdownWithheld, + }); + + // Then: three with results, and Wales's other 29 withheld + expect(container.querySelectorAll('[data-area-id]')).toHaveLength(3); + expect(withheldHexagons(container).length).toBe(WELSH_CONSTITUENCY_COUNT - 3); + expect(withheldHexagon(container, CARDIFF_EAST_CODE)).toBeInTheDocument(); + }); + + test('given a Wales breakdown then the local authority hex map draws every Welsh local authority', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: breakdownWithheld, + }); + + // When + await user.click(screen.getByRole('tab', { name: 'Local authorities' })); + + // Then: Cardiff and Powys with results, and Wales's other 20 withheld + expect(container.querySelectorAll('[data-area-id]')).toHaveLength(2); + expect(withheldHexagons(container).length).toBe(WELSH_LOCAL_AUTHORITY_COUNT - 2); + expect(withheldHexagon(container, SWANSEA_CODE)).toBeInTheDocument(); + }); + + test('given a UK-wide breakdown then the hex maps draw all 650 constituencies and 361 local authorities', async () => { + // Given: one constituency and one local authority with results + const { container, user } = await renderHexMap({ + output: breakdownOutput, + region: UK_WIDE_REGION, + withheld: breakdownWithheld, + }); + + // Then + expect(container.querySelectorAll('svg polygon').length).toBe(EXPECTED_CONSTITUENCY_COUNT); + expect(withheldHexagons(container).length).toBe(EXPECTED_CONSTITUENCY_COUNT - 1); + + // When + await user.click(screen.getByRole('tab', { name: 'Local authorities' })); + + // Then + expect(container.querySelectorAll('svg polygon').length).toBe(EXPECTED_LOCAL_AUTHORITY_COUNT); + expect(withheldHexagons(container).length).toBe(EXPECTED_LOCAL_AUTHORITY_COUNT - 1); + }); + + test('given a withheld hexagon hovered then its tooltip names it and reads not reported', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: breakdownWithheld, + }); + + // When + await user.hover(withheldHexagon(container, CARDIFF_EAST_CODE)!); + + // Then + expect(screen.getByText(`${CARDIFF_EAST_NAME}: ${NOT_REPORTED_TEXT}`)).toBeInTheDocument(); + }); + + test('given a withheld local authority hovered then its tooltip names it and reads not reported', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: breakdownWithheld, + }); + await user.click(screen.getByRole('tab', { name: 'Local authorities' })); + + // When + await user.hover(withheldHexagon(container, SWANSEA_CODE)!); + + // Then + expect(screen.getByText(`${SWANSEA_NAME}: ${NOT_REPORTED_TEXT}`)).toBeInTheDocument(); + }); + + test('given withheld areas then the rankings list only the areas with results', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: breakdownWithheld, + }); + + // Then + expect(withheldHexagons(container).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: /^Show details for / })).toHaveLength(3); + expect( + screen.queryByRole('button', { name: `Show details for ${CARDIFF_EAST_NAME}` }) + ).not.toBeInTheDocument(); + }); + + test('given a withheld hexagon clicked then no details show', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: breakdownWithheld, + }); + + const cardiffEast = withheldHexagon(container, CARDIFF_EAST_CODE); + expect(cardiffEast).toBeInTheDocument(); + + // When + await user.click(cardiffEast!); + + // Then + expect(screen.queryByRole('region', { name: /details$/ })).not.toBeInTheDocument(); + }); + + test('given a saved result then the hex map draws only its areas, with no withheld ones', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + }); + + // Then + expect(container.querySelectorAll('svg polygon')).toHaveLength(3); + expect(withheldHexagons(container).length).toBe(0); + }); + + test('given a breakdown of a saved level and a computed one then only the computed level draws withheld areas', async () => { + // Given: constituencies saved, local authorities from the breakdown + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: { local_authority: SOME_WITHHELD_LOCAL_AUTHORITIES }, + }); + + // Then + expect(withheldHexagons(container).length).toBe(0); + + // When + await user.click(screen.getByRole('tab', { name: 'Local authorities' })); + + // Then + expect(withheldHexagons(container).length).toBe(WELSH_LOCAL_AUTHORITY_COUNT - 2); + }); + + test('given a breakdown area without a code then the hex map keeps the result’s positions, untiled, and draws no withheld areas', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED, + region: WALES_REGION, + withheld: breakdownWithheld, + }); + + // Then + expect([...container.querySelectorAll('[data-area-id]')].some(isPointyTopped)).toBe(false); + expect(withheldHexagons(container).length).toBe(0); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx b/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx index 8b34e3a15..ad8055e63 100644 --- a/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx @@ -15,6 +15,7 @@ import { MAP_LEGEND_NAME, mockUKBoundaryFetch, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; +import { EXPECTED_CONSTITUENCY_COUNT } from '@/tests/fixtures/data/uk_geography/ukGeographyReference'; import { COMPLETE_RESPONSE } from '@/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks'; import { MOCK_UK_REPORT_OUTPUT } from '@/tests/fixtures/pages/constituency/constituencyComponentMocks'; import { @@ -96,10 +97,14 @@ describe('ConstituencySubPage', () => { expect( await screen.findByText('Absolute household income change by constituency') ).toBeInTheDocument(); - // The map's one hexagon is the computed area: hovering it names it. + // The map's one hexagon with a value is the computed area: hovering it + // names it. The UK's other 649 constituencies are drawn as withheld. await user.click(screen.getByRole('tab', { name: HEX_MAP_TAB })); - const hexagons = container.querySelectorAll('svg polygon'); + const hexagons = container.querySelectorAll('svg polygon:not([data-missing-area-id])'); expect(hexagons).toHaveLength(1); + expect(container.querySelectorAll('[data-missing-area-id]').length).toBe( + EXPECTED_CONSTITUENCY_COUNT - 1 + ); await user.hover(hexagons[0]); expect(screen.getByText(new RegExp(`^${BREAKDOWN_CONSTITUENCY_NAME}:`))).toBeInTheDocument(); }); diff --git a/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx b/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx index b9f16f834..7b6ccbeef 100644 --- a/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx @@ -18,6 +18,7 @@ import { MAP_LEGEND_NAME, mockUKBoundaryFetch, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; +import { EXPECTED_LOCAL_AUTHORITY_COUNT } from '@/tests/fixtures/data/uk_geography/ukGeographyReference'; import { COMPLETE_RESPONSE, COMPLETE_RESPONSE_WITH_UNLISTED_LOCAL_AUTHORITY, @@ -156,10 +157,14 @@ describe('LocalAuthoritySubPage', () => { expect( await screen.findByText('Absolute household income change by local authority') ).toBeInTheDocument(); - // The map's one hexagon is the computed area: hovering it names it. + // The map's one hexagon with a value is the computed area: hovering + // it names it. The UK's other 360 local authorities are drawn as withheld. await user.click(screen.getByRole('tab', { name: HEX_MAP_TAB })); - const hexagons = container.querySelectorAll('svg polygon'); + const hexagons = container.querySelectorAll('svg polygon:not([data-missing-area-id])'); expect(hexagons).toHaveLength(1); + expect(container.querySelectorAll('[data-missing-area-id]').length).toBe( + EXPECTED_LOCAL_AUTHORITY_COUNT - 1 + ); await user.hover(hexagons[0]); expect( screen.getByText(new RegExp(`^${BREAKDOWN_LOCAL_AUTHORITY_NAME}:`)) diff --git a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx index e2a0f5c3d..f049cefe2 100644 --- a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx @@ -4,6 +4,7 @@ import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { AbsoluteChangeByConstituency } from '@/pages/report-output/constituency/AbsoluteChangeByConstituency'; import { UK_LOCAL_AREAS_OUTPUT, + UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED, UK_LOCAL_AREAS_OUTPUT_NO_CODES, WALES_REGION, } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; @@ -17,6 +18,10 @@ import { mockUKBoundaryFetch, NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; +import { + CARDIFF_EAST_NAME, + WELSH_CONSTITUENCY_COUNT, +} from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; import { MOCK_UK_REPORT_OUTPUT, MOCK_UK_REPORT_OUTPUT_NO_CONSTITUENCY, @@ -240,3 +245,71 @@ describe('AbsoluteChangeByConstituency hex map placement', () => { expect(v2Hexagons(container).some(isPointyTopped)).toBe(false); }); }); + +describe('AbsoluteChangeByConstituency withheld areas on the hex map', () => { + /** The chart's hex map, with the hex grid chosen. */ + async function renderHexMap(props: Parameters[0]) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return { container, user }; + } + + /** The hexagons of areas the result leaves out, which have no value. */ + function withheldHexagons(container: HTMLElement): Element[] { + return [...container.querySelectorAll('[data-missing-area-id]')]; + } + + test('given withheld constituencies then the hex map draws every Welsh constituency, the withheld ones without a value', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_CONSTITUENCIES, + }); + + // Then: three with results, and Wales's other 29 withheld + expect(v2Hexagons(container).length).toBe(WELSH_CONSTITUENCY_COUNT); + expect(withheldHexagons(container).length).toBe(WELSH_CONSTITUENCY_COUNT - 3); + }); + + test('given a withheld hexagon hovered then its tooltip names it and reads not reported', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_CONSTITUENCIES, + }); + + // When + await user.hover(container.querySelector(`[data-missing-area-id="${CARDIFF_EAST_CODE}"]`)!); + + // Then + expect(screen.getByText(`${CARDIFF_EAST_NAME}: ${NOT_REPORTED_TEXT}`)).toBeInTheDocument(); + }); + + test('given a saved result then the hex map draws only its areas, with no withheld ones', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + }); + + // Then + expect(v2Hexagons(container)).toHaveLength(3); + expect(withheldHexagons(container).length).toBe(0); + }); + + test('given withheld constituencies and one without a code then the hex map draws no withheld areas', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED, + region: WALES_REGION, + withheld: SOME_WITHHELD_CONSTITUENCIES, + }); + + // Then + expect(withheldHexagons(container).length).toBe(0); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx index ad3c0f52f..5be63c77a 100644 --- a/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/RelativeChangeByConstituency.test.tsx @@ -4,6 +4,7 @@ import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { RelativeChangeByConstituency } from '@/pages/report-output/constituency/RelativeChangeByConstituency'; import { UK_LOCAL_AREAS_OUTPUT, + UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED, UK_LOCAL_AREAS_OUTPUT_NO_CODES, WALES_REGION, } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; @@ -16,6 +17,10 @@ import { mockUKBoundaryFetch, NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; +import { + CARDIFF_EAST_NAME, + WELSH_CONSTITUENCY_COUNT, +} from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; import { MOCK_UK_REPORT_OUTPUT, MOCK_UK_REPORT_OUTPUT_NO_CONSTITUENCY, @@ -207,3 +212,71 @@ describe('RelativeChangeByConstituency disclosure control', () => { expect(screen.getByRole('tooltip')).toHaveTextContent(`Cardiff East${NOT_REPORTED_TEXT}`); }); }); + +describe('RelativeChangeByConstituency withheld areas on the hex map', () => { + /** The chart's hex map, with the hex grid chosen. */ + async function renderHexMap(props: Parameters[0]) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return { container, user }; + } + + /** The hexagons of areas the result leaves out, which have no value. */ + function withheldHexagons(container: HTMLElement): Element[] { + return [...container.querySelectorAll('[data-missing-area-id]')]; + } + + test('given withheld constituencies then the hex map draws every Welsh constituency, the withheld ones without a value', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_CONSTITUENCIES, + }); + + // Then: three with results, and Wales's other 29 withheld + expect(v2Hexagons(container).length).toBe(WELSH_CONSTITUENCY_COUNT); + expect(withheldHexagons(container).length).toBe(WELSH_CONSTITUENCY_COUNT - 3); + }); + + test('given a withheld hexagon hovered then its tooltip names it and reads not reported', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_CONSTITUENCIES, + }); + + // When + await user.hover(container.querySelector(`[data-missing-area-id="${CARDIFF_EAST_CODE}"]`)!); + + // Then + expect(screen.getByText(`${CARDIFF_EAST_NAME}: ${NOT_REPORTED_TEXT}`)).toBeInTheDocument(); + }); + + test('given a saved result then the hex map draws only its areas, with no withheld ones', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + }); + + // Then + expect(v2Hexagons(container)).toHaveLength(3); + expect(withheldHexagons(container).length).toBe(0); + }); + + test('given withheld constituencies and one without a code then the hex map draws no withheld areas', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED, + region: WALES_REGION, + withheld: SOME_WITHHELD_CONSTITUENCIES, + }); + + // Then + expect(withheldHexagons(container).length).toBe(0); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx index 1756fbd41..0db720668 100644 --- a/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx +++ b/app/src/tests/unit/pages/report-output/local-authority/AbsoluteChangeByLocalAuthority.test.tsx @@ -15,6 +15,11 @@ import { mockUKBoundaryFetch, NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; +import { + SWANSEA_CODE, + SWANSEA_NAME, + WELSH_LOCAL_AUTHORITY_COUNT, +} from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; import { MOCK_UK_REPORT_OUTPUT_NO_LA_DATA, MOCK_UK_REPORT_OUTPUT_WITH_LA, @@ -249,3 +254,70 @@ describe('AbsoluteChangeByLocalAuthority disclosure control', () => { expect(screen.getByRole('tooltip')).toHaveTextContent(`Hartlepool${NOT_REPORTED_TEXT}`); }); }); + +describe('AbsoluteChangeByLocalAuthority withheld areas on the hex map', () => { + /** The chart's hex map, with the hex grid chosen. */ + async function renderHexMap(props: Parameters[0]) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return { container, user }; + } + + /** The hexagons of areas the result leaves out, which have no value. */ + function withheldHexagons(container: HTMLElement): Element[] { + return [...container.querySelectorAll('[data-missing-area-id]')]; + } + + test('given withheld local authorities then the hex map draws every Welsh local authority, the withheld ones without a value', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_LOCAL_AUTHORITIES, + }); + + // Then: Cardiff and Powys with results, and Wales's other 20 withheld + expect(v2Hexagons(container).length).toBe(WELSH_LOCAL_AUTHORITY_COUNT); + expect(withheldHexagons(container).length).toBe(WELSH_LOCAL_AUTHORITY_COUNT - 2); + }); + + test('given a withheld hexagon hovered then its tooltip names it and reads not reported', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_LOCAL_AUTHORITIES, + }); + + // When + await user.hover(container.querySelector(`[data-missing-area-id="${SWANSEA_CODE}"]`)!); + + // Then + expect(screen.getByText(`${SWANSEA_NAME}: ${NOT_REPORTED_TEXT}`)).toBeInTheDocument(); + }); + + test('given a saved result then the hex map draws only its areas, with no withheld ones', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + }); + + // Then + expect(v2Hexagons(container)).toHaveLength(2); + expect(withheldHexagons(container).length).toBe(0); + }); + + test('given withheld local authorities on pre-2023 codes then the hex map draws no withheld areas', async () => { + // When + const { container } = await renderHexMap({ + output: MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA, + withheld: SOME_WITHHELD_LOCAL_AUTHORITIES, + }); + + // Then + expect(withheldHexagons(container).length).toBe(0); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx b/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx index 052fc958f..e99182185 100644 --- a/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx +++ b/app/src/tests/unit/pages/report-output/local-authority/RelativeChangeByLocalAuthority.test.tsx @@ -2,7 +2,10 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { RelativeChangeByLocalAuthority } from '@/pages/report-output/local-authority/RelativeChangeByLocalAuthority'; -import { UK_LOCAL_AREAS_OUTPUT } from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; +import { + UK_LOCAL_AREAS_OUTPUT, + WALES_REGION, +} from '@/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks'; import { NATIONAL_OUTPUT_WITHOUT_AREAS } from '@/tests/fixtures/components/flagship/report/ukLocalAreaSectionMocks'; import { SOME_WITHHELD_LOCAL_AUTHORITIES } from '@/tests/fixtures/components/report/UKLocalAreaComputePanelMocks'; import { @@ -12,9 +15,15 @@ import { mockUKBoundaryFetch, NOT_REPORTED_TEXT, } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; +import { + SWANSEA_CODE, + SWANSEA_NAME, + WELSH_LOCAL_AUTHORITY_COUNT, +} from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; import { MOCK_UK_REPORT_OUTPUT_NO_LA_DATA, MOCK_UK_REPORT_OUTPUT_WITH_LA, + MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA, MOCK_UK_REPORT_OUTPUT_WITH_STACKED_LA, } from '@/tests/fixtures/pages/local-authority/localAuthorityComponentMocks'; import { @@ -221,3 +230,70 @@ describe('RelativeChangeByLocalAuthority disclosure control', () => { expect(screen.getByRole('tooltip')).toHaveTextContent(`Hartlepool${NOT_REPORTED_TEXT}`); }); }); + +describe('RelativeChangeByLocalAuthority withheld areas on the hex map', () => { + /** The chart's hex map, with the hex grid chosen. */ + async function renderHexMap(props: Parameters[0]) { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + return { container, user }; + } + + /** The hexagons of areas the result leaves out, which have no value. */ + function withheldHexagons(container: HTMLElement): Element[] { + return [...container.querySelectorAll('[data-missing-area-id]')]; + } + + test('given withheld local authorities then the hex map draws every Welsh local authority, the withheld ones without a value', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_LOCAL_AUTHORITIES, + }); + + // Then: Cardiff and Powys with results, and Wales's other 20 withheld + expect(v2Hexagons(container).length).toBe(WELSH_LOCAL_AUTHORITY_COUNT); + expect(withheldHexagons(container).length).toBe(WELSH_LOCAL_AUTHORITY_COUNT - 2); + }); + + test('given a withheld hexagon hovered then its tooltip names it and reads not reported', async () => { + // Given + const { container, user } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + withheld: SOME_WITHHELD_LOCAL_AUTHORITIES, + }); + + // When + await user.hover(container.querySelector(`[data-missing-area-id="${SWANSEA_CODE}"]`)!); + + // Then + expect(screen.getByText(`${SWANSEA_NAME}: ${NOT_REPORTED_TEXT}`)).toBeInTheDocument(); + }); + + test('given a saved result then the hex map draws only its areas, with no withheld ones', async () => { + // When + const { container } = await renderHexMap({ + output: UK_LOCAL_AREAS_OUTPUT, + region: WALES_REGION, + }); + + // Then + expect(v2Hexagons(container)).toHaveLength(2); + expect(withheldHexagons(container).length).toBe(0); + }); + + test('given withheld local authorities on pre-2023 codes then the hex map draws no withheld areas', async () => { + // When + const { container } = await renderHexMap({ + output: MOCK_UK_REPORT_OUTPUT_WITH_PRE_2023_LA, + withheld: SOME_WITHHELD_LOCAL_AUTHORITIES, + }); + + // Then + expect(withheldHexagons(container).length).toBe(0); + }); +}); From f8a4d1f896afdd85bd0484d738df4ace731e9eca Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:03:42 +0400 Subject: [PATCH 11/21] Read "Not reported" only on a breakdown level's v3 geographic map v3's geographic map labelled every boundary without a result "Not reported" whenever the report had a breakdown, including on a saved level beside it, which disclosure control did not apply to. It now does so only on a level the breakdown supplied, as the hex map does; a saved level's boundary without a result reads "No data". (cherry picked from commit 78b1e8edcec813d5256e07e7fec36fa0c3422716) Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/UKLocalAreaMaps.tsx | 5 ++- .../flagship/report/UKLocalAreaMaps.test.tsx | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/app/src/components/flagship/report/UKLocalAreaMaps.tsx b/app/src/components/flagship/report/UKLocalAreaMaps.tsx index ef2089b95..f48d4b6db 100644 --- a/app/src/components/flagship/report/UKLocalAreaMaps.tsx +++ b/app/src/components/flagship/report/UKLocalAreaMaps.tsx @@ -403,8 +403,9 @@ export default function UKLocalAreaMaps({ config={{ ...mapConfig, height: GEOGRAPHIC_MAP_HEIGHT }} focusNation={ukNationFromRegion(region)} exportRef={mapRef} - // A breakdown's missing areas are the ones disclosure control withheld. - missingAreaText={withheld ? UK_WITHHELD_AREA_TEXT : undefined} + // A breakdown level's missing areas are the ones disclosure control + // withheld; a saved level's have no data. + missingAreaText={levelWithheld ? UK_WITHHELD_AREA_TEXT : undefined} navigable selectedId={selectedArea ? selectedName : null} onAreaClick={toggleSelected} diff --git a/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx b/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx index cf949f6e8..cb103d9e2 100644 --- a/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx +++ b/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx @@ -26,6 +26,7 @@ import { CARDIFF_EAST_CODE, CLWYD_EAST_CODE, GOWER_CODE, + HARTLEPOOL_CODE, MAP_LEGEND_NAME, mockUKBoundaryFetch, MONMOUTHSHIRE_CODE, @@ -449,6 +450,44 @@ describe('UKLocalAreaMaps geographic map', () => { expect(within(tooltip).getByText(NOT_REPORTED_TEXT)).toBeInTheDocument(); }); + test('given a saved level beside a breakdown level then its boundary without a result reads no data, not withheld', async () => { + // Given: constituencies saved, local authorities from the breakdown + render( + , + 'uk' + ); + + // When + fireEvent.mouseEnter(await boundary(CARDIFF_EAST_CODE)); + + // Then + expect(within(screen.getByRole('tooltip')).getByText('No data')).toBeInTheDocument(); + }); + + test('given a breakdown level beside a saved one then its boundary without a result reads not reported', async () => { + // Given: constituencies saved, local authorities from the breakdown, UK-wide + const user = userEvent.setup(); + render( + , + 'uk' + ); + await user.click(screen.getByRole('tab', { name: 'Local authorities' })); + + // When: Hartlepool has no result + fireEvent.mouseEnter(await boundary(HARTLEPOOL_CODE)); + + // Then + expect(within(screen.getByRole('tooltip')).getByText(NOT_REPORTED_TEXT)).toBeInTheDocument(); + }); + test('given a saved result without an area then its boundary reads no data, not withheld', async () => { // Given render(, 'uk'); From 7a10d52db1880e005a89a3ffd8d6a7181a30452d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:16:35 +0400 Subject: [PATCH 12/21] Keep a withheld hexagon's hatching distinct on the dense UK map On the UK-wide constituency hex map each hexagon's radius is about 4.7 pixels, so the hatching's stripes repeated 2.3 pixels apart. That is close enough to blend into a flat light grey, which the colour scale's grey arm holds, so a withheld constituency could read as a small loss. The stripes now repeat no closer than 4 SVG units (pixels at full width), and a withheld hexagon has a grey edge where a valued one's is the background colour, kept at the same screen width when zoomed. Adapted for main: the two tests draw their maps on the tiled grid, as the UK hex maps draw withheld areas, since main's map is untiled unless asked. (cherry picked from commit 184178f5b7673165cc2c7b59fa264e36e8a82bec) Co-Authored-By: Claude Opus 5.5 --- .../components/visualization/HexagonalMap.tsx | 18 ++++++--- .../components/visualization/hexMapMocks.ts | 18 +++++++++ .../visualization/HexagonalMap.test.tsx | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/app/src/components/visualization/HexagonalMap.tsx b/app/src/components/visualization/HexagonalMap.tsx index 9030cb757..6c5be31d0 100644 --- a/app/src/components/visualization/HexagonalMap.tsx +++ b/app/src/components/visualization/HexagonalMap.tsx @@ -301,14 +301,19 @@ const NO_MISSING_AREAS: HexMapMissingArea[] = []; const DEFAULT_MISSING_AREA_TEXT = 'No data'; /** - * A missing area's hatching: grey stripes on the map's background, which - * no colour on the scale resembles. The stripes repeat at half a hexagon's - * radius, so each hexagon shows a few at any size. + * A missing area's look: grey stripes on the map's background, which no + * colour on the scale resembles, inside a grey edge where a valued + * hexagon's edge is the background colour. The stripes repeat at half a + * hexagon's radius, but never closer than 4 SVG units (4 pixels on a map + * drawn at full width): closer, they blend into a flat grey that the + * scale's grey arm holds, as on the UK constituency map. */ const MISSING_AREA_GROUND_COLOR = colors.background.primary; const MISSING_AREA_STRIPE_COLOR = colors.gray[300]; +const MISSING_AREA_EDGE_COLOR = colors.gray[400]; +const MISSING_AREA_EDGE_WIDTH = 0.75; const MISSING_AREA_STRIPES_PER_RADIUS = 2; -const MIN_MISSING_AREA_STRIPE_SPACING = 2; +const MIN_MISSING_AREA_STRIPE_SPACING = 4; /** * SVG polygon points for a hexagon centred at (cx, cy): flat-topped, or @@ -585,8 +590,9 @@ export function HexagonalMap({ key={`missing-${area.id}`} points={hexPoints(toSvgX(area.x), toSvgY(area.y), hexPixelSize, tiled)} fill={`url(#${hatchingId})`} - stroke={colors.background.primary} - strokeWidth={0.5} + stroke={MISSING_AREA_EDGE_COLOR} + strokeWidth={MISSING_AREA_EDGE_WIDTH} + vectorEffect={outlineEffect} data-missing-area-id={area.id} onMouseEnter={(event) => showTooltip(event, `${area.label}: ${missingAreaText}`)} onMouseLeave={() => setTooltip(null)} diff --git a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts index 428fc9b10..fb3efbb4d 100644 --- a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts +++ b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts @@ -1,3 +1,4 @@ +import { colors } from '@/designTokens'; import type { HexMapDataPoint, HexMapMissingArea } from '@/types/visualization/HexMapDataPoint'; export const WESTMINSTER_NORTH: HexMapDataPoint = { @@ -102,3 +103,20 @@ export const MISSING_FAR_EAST: HexMapMissingArea = { /** The text a missing area's tooltip reads when the caller says why it has no value. */ export const NOT_REPORTED_TEXT = 'Not reported'; + +/** An area without a value inside MOCK_UK_SHAPED_HEX_DATA's grid, which is as dense as the UK constituency map. */ +export const MISSING_UK_SHAPED_AREA: HexMapMissingArea = { + id: 'glasgow', + label: 'Glasgow', + x: 50, + y: -12, +}; + +/** + * The closest a missing area's stripes may repeat, in screen pixels: closer, + * they blend into a flat grey that the colour scale's grey arm holds. + */ +export const MIN_MISSING_AREA_STRIPE_PIXELS = 4; + +/** A missing area's edge, where a valued hexagon's is the background colour. */ +export const MISSING_AREA_EDGE_COLOR = colors.gray[400]; diff --git a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx index 01c8b2574..b36d85847 100644 --- a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx +++ b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx @@ -2,8 +2,11 @@ import { fireEvent, render, screen, userEvent } from '@test-utils'; import { describe, expect, test, vi } from 'vitest'; import { HexagonalMap, zoomAbout } from '@/components/visualization/HexagonalMap'; import { + MIN_MISSING_AREA_STRIPE_PIXELS, + MISSING_AREA_EDGE_COLOR, MISSING_FAR_EAST, MISSING_NORTH_EAST, + MISSING_UK_SHAPED_AREA, MOCK_HEX_MAP_DATA, MOCK_MIXED_VALUES, MOCK_NEGATIVE_VALUES, @@ -689,6 +692,41 @@ describe('HexagonalMap missing areas', () => { expect(Math.max(...allVertices.map(([, vertexY]) => vertexY))).toBeLessThanOrEqual(frameHeight); }); + test('given a map as dense as the UK constituency map then the hatching’s stripes are at least 4 pixels apart', () => { + // When: hexagons about 4.7 pixels across their radius, whose stripes would otherwise blend into a grey on the scale + const { container } = render( + + ); + + // Then + const hatching = container.querySelector('pattern')!; + expect(Number(hatching.getAttribute('width'))).toBeGreaterThanOrEqual( + MIN_MISSING_AREA_STRIPE_PIXELS + ); + }); + + test('given a missing area then its edge is grey, unlike the valued hexagons’ edges', () => { + // When + const { container } = render( + + ); + + // Then + const valuedEdge = container.querySelector('[data-area-id="west"]')!.getAttribute('stroke'); + const missingEdge = missingHexagon(container, MISSING_NORTH_EAST.id)!.getAttribute('stroke'); + expect(missingEdge).toBe(MISSING_AREA_EDGE_COLOR); + expect(missingEdge).not.toBe(valuedEdge); + }); + test('given no missing areas then the map draws no hatching', () => { // When const { container } = render(); From 9ec3bf812877955880aa751ea620dbc78e49a357 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:17:18 +0400 Subject: [PATCH 13/21] Say in the hex map's docs that a withheld hexagon has a grey edge Adapted for main: the README keeps this branch's sentence that withheld areas are drawn only on the tiled grid. (cherry picked from commit dac1a6d986aecfa4edbaef996f82d74f8a9da263) Co-Authored-By: Claude Opus 5.5 --- app/src/components/visualization/HexagonalMap.tsx | 4 ++-- app/src/data/ukHexLayouts/README.md | 2 +- app/src/types/visualization/HexMapDataPoint.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/components/visualization/HexagonalMap.tsx b/app/src/components/visualization/HexagonalMap.tsx index 6c5be31d0..62153f549 100644 --- a/app/src/components/visualization/HexagonalMap.tsx +++ b/app/src/components/visualization/HexagonalMap.tsx @@ -17,8 +17,8 @@ interface HexagonalMapProps { /** * Opt-in: areas the map places but has no value for, e.g. ones disclosure - * control withheld. Each is drawn hatched, outside the colour scale, with - * a tooltip of its name and `missingAreaText`. They take no part in the + * control withheld. Each is drawn hatched with a grey edge, outside the + * colour scale, with a tooltip of its name and `missingAreaText`. They take no part in the * colour range, and cannot be selected. */ missingAreas?: HexMapMissingArea[]; diff --git a/app/src/data/ukHexLayouts/README.md b/app/src/data/ukHexLayouts/README.md index 4a640d02d..123555cc4 100644 --- a/app/src/data/ukHexLayouts/README.md +++ b/app/src/data/ukHexLayouts/README.md @@ -2,7 +2,7 @@ Hex-grid positions for the UK local-area hex maps, one per area, keyed by ONS code. The v3 "Local areas" tab and the v2 constituency and local authority tabs place each area from these files by its ONS code, through `placeOnUKHexLayout`, instead of at the `x` and `y` that the Public API sends with each area. If any area in a result has no code, or a code these files lack (such as a district abolished in April 2023), every area in that result falls back to its `x` and `y`, on the untiled grid, so the map never mixes the two sets of positions. -`withheldUKHexAreas` lists the areas of a report's region that a result leaves out, each at its position here and named from the name files below. The region's areas are every code in these files for a UK-wide report, and the nation's codes for a nation's report. For a level from a local-area breakdown, which disclosure control applies to, the hex maps draw each such area hatched, outside the colour scale, with "Not reported" in its tooltip. They are drawn only on the tiled grid: a result that falls back to its `x` and `y` lists none. A saved result, which disclosure control did not apply to, draws only its own areas. +`withheldUKHexAreas` lists the areas of a report's region that a result leaves out, each at its position here and named from the name files below. The region's areas are every code in these files for a UK-wide report, and the nation's codes for a nation's report. For a level from a local-area breakdown, which disclosure control applies to, the hex maps draw each such area hatched with a grey edge, outside the colour scale, with "Not reported" in its tooltip. They are drawn only on the tiled grid: a result that falls back to its `x` and `y` lists none. A saved result, which disclosure control did not apply to, draws only its own areas. | File | Areas | Boundaries | Source file | |---|---|---|---| diff --git a/app/src/types/visualization/HexMapDataPoint.ts b/app/src/types/visualization/HexMapDataPoint.ts index c416eb8a2..7c4f92bdd 100644 --- a/app/src/types/visualization/HexMapDataPoint.ts +++ b/app/src/types/visualization/HexMapDataPoint.ts @@ -44,7 +44,8 @@ export interface HexMapDataPoint { /** * An area the hex map places but has no value for, such as one disclosure - * control withheld. The map draws it hatched, outside its colour scale. + * control withheld. The map draws it hatched with a grey edge, outside its + * colour scale. */ export interface HexMapMissingArea { /** Unique identifier for this area, e.g. its code */ From 08f9cb07320d58750c4097bcd42944c3119a7cb9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:50:21 +0400 Subject: [PATCH 14/21] Rewrap HexagonalMap's missingAreas documentation The cherry-pick's conflict resolution left one overlong row; the documentation now also says that missing areas take the valued areas' grid. Co-Authored-By: Claude Opus 5.5 --- app/src/components/visualization/HexagonalMap.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/components/visualization/HexagonalMap.tsx b/app/src/components/visualization/HexagonalMap.tsx index 62153f549..cc11f7fde 100644 --- a/app/src/components/visualization/HexagonalMap.tsx +++ b/app/src/components/visualization/HexagonalMap.tsx @@ -17,9 +17,10 @@ interface HexagonalMapProps { /** * Opt-in: areas the map places but has no value for, e.g. ones disclosure - * control withheld. Each is drawn hatched with a grey edge, outside the - * colour scale, with a tooltip of its name and `missingAreaText`. They take no part in the - * colour range, and cannot be selected. + * control withheld. Each is drawn on the same grid as the valued areas, + * hatched with a grey edge, outside the colour scale, with a tooltip of + * its name and `missingAreaText`. They take no part in the colour range, + * and cannot be selected. */ missingAreas?: HexMapMissingArea[]; From f7575a8de8687a43358a6dbeb375d8a8c1267f46 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:22:51 +0400 Subject: [PATCH 15/21] Place a UK hex map and its withheld areas with one shared function The four v2 charts and v3's local-area map each placed their areas from the layout and then worked out the withheld ones in a second step. placeUKHexMap does both: placeOnUKHexLayout's all-or-none placement, and, for a disclosure-controlled level on the tiled grid, the region's areas the result leaves out. All five maps now call it, with no change in what they draw; their tests pass unchanged. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/UKLocalAreaMaps.tsx | 23 ++++----- .../visualization/choropleth/ukHexLayout.ts | 30 ++++++++++++ .../AbsoluteChangeByConstituency.tsx | 24 ++++------ .../RelativeChangeByConstituency.tsx | 24 ++++------ .../AbsoluteChangeByLocalAuthority.tsx | 24 ++++------ .../RelativeChangeByLocalAuthority.tsx | 24 ++++------ .../choropleth/ukHexLayout.test.ts | 48 +++++++++++++++++++ 7 files changed, 119 insertions(+), 78 deletions(-) diff --git a/app/src/components/flagship/report/UKLocalAreaMaps.tsx b/app/src/components/flagship/report/UKLocalAreaMaps.tsx index f48d4b6db..f5e79fa39 100644 --- a/app/src/components/flagship/report/UKLocalAreaMaps.tsx +++ b/app/src/components/flagship/report/UKLocalAreaMaps.tsx @@ -39,10 +39,7 @@ import { ukNationFromRegion, } from '@/components/visualization/choropleth/ukAreaBoundaries'; import { UKAreaChoroplethMap } from '@/components/visualization/choropleth/UKAreaChoroplethMap'; -import { - placeOnUKHexLayout, - withheldUKHexAreas, -} from '@/components/visualization/choropleth/ukHexLayout'; +import { placeUKHexMap } from '@/components/visualization/choropleth/ukHexLayout'; import { HexagonalMap } from '@/components/visualization/HexagonalMap'; import { colors, spacing, typography } from '@/designTokens'; import type { ReportOutputSocietyWideByConstituency } from '@/types/metadata/ReportOutputSocietyWideByConstituency'; @@ -268,17 +265,15 @@ export default function UKLocalAreaMaps({ [areas, mode] ); - // The hex map places each area by its code in the app's layout, tiled. - // A result with any area the layout cannot place keeps all the API's - // positions, on their square grid. - const hexMap = useMemo(() => placeOnUKHexLayout(level, mapData), [level, mapData]); // A level the breakdown supplied is disclosure-controlled: the areas of - // the region it leaves out are the ones withheld, which the hex map draws - // as not reported, on the tiled grid only. A saved level has none. + // the region it leaves out are the ones withheld. A saved level has none. const levelWithheld = withheld?.[level] !== undefined; - const withheldHexAreas = useMemo( - () => (levelWithheld && hexMap.tiled ? withheldUKHexAreas(level, mapData, region) : []), - [levelWithheld, hexMap.tiled, level, mapData, region] + // The hex map places each area by its code in the app's layout, tiled, + // and draws the withheld ones as not reported. A result with any area the + // layout cannot place keeps all the API's positions, on their square grid. + const hexMap = useMemo( + () => placeUKHexMap(level, mapData, { disclosureControlled: levelWithheld, region }), + [level, mapData, levelWithheld, region] ); const decimals = useMemo(() => percentDecimals(mapData.map((point) => point.value)), [mapData]); @@ -414,7 +409,7 @@ export default function UKLocalAreaMaps({ ) : ( (areas ? transformConstituencyAbsoluteChange(areas) : []), [areas]); - // The hex map places each constituency by its code in the app's layout, as v3's does. - const hexMap = useMemo(() => placeOnUKHexLayout('constituency', mapData), [mapData]); - // The constituencies a breakdown leaves out are the ones disclosure control - // withheld: the hex map draws each in its place, as not reported, - // on the tiled grid only. - const withheldHexAreas = useMemo( - () => - withheld === undefined || !hexMap.tiled - ? [] - : withheldUKHexAreas('constituency', mapData, region), - [withheld, hexMap.tiled, mapData, region] + // The hex map places each constituency by its code in the app's layout, as v3's + // does, and draws the ones a breakdown withheld as not reported. + const disclosureControlled = withheld !== undefined; + const hexMap = useMemo( + () => placeUKHexMap('constituency', mapData, { disclosureControlled, region }), + [mapData, disclosureControlled, region] ); const notes = ; @@ -109,7 +101,7 @@ export function AbsoluteChangeByConstituency({ ) : ( (areas ? transformConstituencyRelativeChange(areas) : []), [areas]); - // The hex map places each constituency by its code in the app's layout, as v3's does. - const hexMap = useMemo(() => placeOnUKHexLayout('constituency', mapData), [mapData]); - // The constituencies a breakdown leaves out are the ones disclosure control - // withheld: the hex map draws each in its place, as not reported, - // on the tiled grid only. - const withheldHexAreas = useMemo( - () => - withheld === undefined || !hexMap.tiled - ? [] - : withheldUKHexAreas('constituency', mapData, region), - [withheld, hexMap.tiled, mapData, region] + // The hex map places each constituency by its code in the app's layout, as v3's + // does, and draws the ones a breakdown withheld as not reported. + const disclosureControlled = withheld !== undefined; + const hexMap = useMemo( + () => placeUKHexMap('constituency', mapData, { disclosureControlled, region }), + [mapData, disclosureControlled, region] ); const notes = ; @@ -109,7 +101,7 @@ export function RelativeChangeByConstituency({ ) : ( (areas ? transformLocalAuthorityAbsoluteChange(areas) : []), [areas] ); - // The hex map places each local authority by its code in the app's layout, as v3's does. - const hexMap = useMemo(() => placeOnUKHexLayout('local_authority', mapData), [mapData]); - // The local authorities a breakdown leaves out are the ones disclosure - // control withheld: the hex map draws each in its place, as not reported, - // on the tiled grid only. - const withheldHexAreas = useMemo( - () => - withheld === undefined || !hexMap.tiled - ? [] - : withheldUKHexAreas('local_authority', mapData, region), - [withheld, hexMap.tiled, mapData, region] + // The hex map places each local authority by its code in the app's layout, as v3's + // does, and draws the ones a breakdown withheld as not reported. + const disclosureControlled = withheld !== undefined; + const hexMap = useMemo( + () => placeUKHexMap('local_authority', mapData, { disclosureControlled, region }), + [mapData, disclosureControlled, region] ); const notes = ; @@ -112,7 +104,7 @@ export function AbsoluteChangeByLocalAuthority({ ) : ( (areas ? transformLocalAuthorityRelativeChange(areas) : []), [areas] ); - // The hex map places each local authority by its code in the app's layout, as v3's does. - const hexMap = useMemo(() => placeOnUKHexLayout('local_authority', mapData), [mapData]); - // The local authorities a breakdown leaves out are the ones disclosure - // control withheld: the hex map draws each in its place, as not reported, - // on the tiled grid only. - const withheldHexAreas = useMemo( - () => - withheld === undefined || !hexMap.tiled - ? [] - : withheldUKHexAreas('local_authority', mapData, region), - [withheld, hexMap.tiled, mapData, region] + // The hex map places each local authority by its code in the app's layout, as v3's + // does, and draws the ones a breakdown withheld as not reported. + const disclosureControlled = withheld !== undefined; + const hexMap = useMemo( + () => placeUKHexMap('local_authority', mapData, { disclosureControlled, region }), + [mapData, disclosureControlled, region] ); const notes = ; @@ -112,7 +104,7 @@ export function RelativeChangeByLocalAuthority({ ) : ( { expect(areas).toEqual([]); }); }); + +describe('placeUKHexMap', () => { + test("given a disclosure-controlled level then it places the result's areas tiled and lists the region's others as withheld", () => { + // When + const hexMap = placeUKHexMap('constituency', WELSH_CONSTITUENCY_POINTS, { + disclosureControlled: true, + region: WALES_REGION, + }); + + // Then + expect(hexMap.tiled).toBe(true); + expect(hexMap.points).toEqual( + placeOnUKHexLayout('constituency', WELSH_CONSTITUENCY_POINTS).points + ); + expect(hexMap.withheldAreas).toHaveLength( + WELSH_CONSTITUENCY_COUNT - WELSH_CONSTITUENCY_POINTS.length + ); + expect(hexMap.withheldAreas.some((area) => area.id === CARDIFF_EAST_CODE)).toBe(true); + }); + + test('given a level that is not disclosure-controlled then it places the areas and lists no withheld ones', () => { + // When + const hexMap = placeUKHexMap('constituency', WELSH_CONSTITUENCY_POINTS, { + disclosureControlled: false, + region: WALES_REGION, + }); + + // Then + expect(hexMap.tiled).toBe(true); + expect(hexMap.withheldAreas).toEqual([]); + }); + + test("given an area the layout cannot place then every area keeps its result's position, untiled, with no withheld areas", () => { + // When + const hexMap = placeUKHexMap('constituency', [BARNSLEY_NORTH_POINT, UNCODED_POINT], { + disclosureControlled: true, + region: UK_WIDE_REGION, + }); + + // Then + expect(hexMap).toEqual({ + points: [BARNSLEY_NORTH_POINT, UNCODED_POINT], + tiled: false, + withheldAreas: [], + }); + }); +}); From e83c161e330761eca13c233c01c1e8a4e45b1ce1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:31:34 +0400 Subject: [PATCH 16/21] Count the withheld-area note by code against the layout, as the map does The note under the UK local-area maps counted a UK-wide report's withheld areas by name against the Public API's region list, and a nation's by code against fixed per-nation totals, while the hex map hatched the region's layout codes the result lacks. The two could disagree: with Ards and North Down published, the note read "1 of 3 local authorities is not reported" against a trimmed region list while the map hatched 359, and a region list from before PolicyEngine/policyengine-api#4024 gave no count at all. withheldUKAreaCodes now holds the one rule, each published area's ONS code against the layout's areas of the report's region, and both withheldUKHexAreas and countWithheldUKAreas follow it. A result that cannot be matched by code (an area without a code, or one outside the region's layout) gives no count, as a nation's report did. The count no longer reads the region list: useUKLocalAreaOutput stops calling useRegions, withheldUKAreasOf and countWithheldUKAreas lose their region and name arguments, and UK_AREA_COUNTS_BY_NATION, now unused, moves into the test fixtures that check the layout's nation counts. Co-Authored-By: Claude Opus 5.5 --- .../adapters/ukLocalAreaBreakdownAdapter.ts | 70 ++---- .../choropleth/ukAreaBoundaries.ts | 10 - .../visualization/choropleth/ukHexLayout.ts | 82 ++++--- app/src/hooks/useUKLocalAreaOutput.ts | 15 +- .../ukLocalAreaBreakdownAdapterMocks.ts | 99 +++----- .../data/uk_geography/ukGeographyReference.ts | 9 + .../hooks/useUKLocalAreaBreakdownMocks.ts | 42 +++- .../ukLocalAreaBreakdownAdapter.test.ts | 217 +++++++----------- .../report/UKLocalAreaSection.test.tsx | 42 ++-- .../tests/unit/data/ukBoundaryFiles.test.ts | 14 +- .../unit/hooks/useUKLocalAreaOutput.test.tsx | 6 - .../ConstituencySubPage.test.tsx | 8 +- .../LocalAuthoritySubPage.test.tsx | 40 +++- .../report-output/MigrationSubPage.test.tsx | 6 - 14 files changed, 306 insertions(+), 354 deletions(-) diff --git a/app/src/adapters/ukLocalAreaBreakdownAdapter.ts b/app/src/adapters/ukLocalAreaBreakdownAdapter.ts index fb74d8393..2c550c868 100644 --- a/app/src/adapters/ukLocalAreaBreakdownAdapter.ts +++ b/app/src/adapters/ukLocalAreaBreakdownAdapter.ts @@ -3,13 +3,8 @@ import { hasLocalAuthorityImpacts, } from '@/adapters/societyWideOutputNormalizer'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import { - UK_AREA_COUNTS_BY_NATION, - ukNationFromCode, - ukNationFromRegion, -} from '@/components/visualization/choropleth/ukAreaBoundaries'; +import { withheldUKAreaCodes } from '@/components/visualization/choropleth/ukHexLayout'; import type { UKReportLocalAreaRequest } from '@/hooks/useUKLocalAreaBreakdown'; -import type { Region } from '@/models/region'; import type { Geography } from '@/types/ingredients/Geography'; import type { Report } from '@/types/ingredients/Report'; import type { Simulation } from '@/types/ingredients/Simulation'; @@ -143,59 +138,25 @@ export type UKBreakdownWithheldAreas = Partial ukNationFromCode(code) !== nation) - ) { - return null; - } - const total = UK_AREA_COUNTS_BY_NATION[level][nation]; - const published = new Set(publishedCodes).size; - return published <= total ? { withheld: total - published, total } : null; - } - const prefix = `${level}/`; - const listedNames = new Set( - regions - .filter((entry) => entry.code.startsWith(prefix)) - .map((entry) => entry.code.slice(prefix.length)) - ); - if (listedNames.size === 0 || publishedNames.some((name) => !listedNames.has(name))) { - return null; - } - return { withheld: listedNames.size - new Set(publishedNames).size, total: listedNames.size }; + const withheld = withheldUKAreaCodes(level, publishedCodes, region); + return withheld ? { withheld: withheld.codes.length, total: withheld.total } : null; } /** @@ -204,8 +165,7 @@ export function countWithheldUKAreas({ */ export function withheldUKAreasOf( breakdown: LocalAreaBreakdown, - region: string | null | undefined, - regions: Region[] + region: string | null | undefined ): UKBreakdownWithheldAreas { const constituencies = breakdown.constituency_impact?.by_constituency ?? {}; const localAuthorities = breakdown.local_authority_impact?.by_local_authority ?? {}; @@ -213,16 +173,12 @@ export function withheldUKAreasOf( constituency: countWithheldUKAreas({ level: 'constituency', region, - publishedNames: Object.keys(constituencies), publishedCodes: Object.values(constituencies).map((area) => area.code), - regions, }), local_authority: countWithheldUKAreas({ level: 'local_authority', region, - publishedNames: Object.keys(localAuthorities), publishedCodes: Object.values(localAuthorities).map((area) => area.code), - regions, }), }; } diff --git a/app/src/components/visualization/choropleth/ukAreaBoundaries.ts b/app/src/components/visualization/choropleth/ukAreaBoundaries.ts index 49ca16c07..8aa6ebc23 100644 --- a/app/src/components/visualization/choropleth/ukAreaBoundaries.ts +++ b/app/src/components/visualization/choropleth/ukAreaBoundaries.ts @@ -41,16 +41,6 @@ export const UK_NATION_BY_CODE_PREFIX: Record = { N: 'northern_ireland', }; -/** - * How many areas of each level each nation has, counted by code prefix in - * the boundary files above (650 constituencies, 361 local authorities). A - * test checks these against the files. - */ -export const UK_AREA_COUNTS_BY_NATION: Record> = { - constituency: { england: 543, wales: 32, scotland: 57, northern_ireland: 18 }, - local_authority: { england: 296, wales: 22, scotland: 32, northern_ireland: 11 }, -}; - /** The nation a report's region names, e.g. "country/wales" is Wales. */ const UK_NATION_BY_REGION: Record = { 'country/england': 'england', diff --git a/app/src/components/visualization/choropleth/ukHexLayout.ts b/app/src/components/visualization/choropleth/ukHexLayout.ts index 7ffc8b42a..c54f6809d 100644 --- a/app/src/components/visualization/choropleth/ukHexLayout.ts +++ b/app/src/components/visualization/choropleth/ukHexLayout.ts @@ -78,19 +78,57 @@ export function placeOnUKHexLayout( }; } +/** The areas of a report's region a result leaves out, by ONS code, and how many the region has. */ +export interface UKWithheldAreaCodes { + codes: string[]; + total: number; +} + +/** + * The ONS codes of a report's region's areas that a result leaves out, by + * the result's codes against the layout, and how many areas the region + * has; null when the result cannot be matched to the layout. The hex map's + * withheld areas and the note's count under it both follow this rule, so + * they always agree. + * + * The region's areas are the layout's: all of them for a UK-wide report + * (`uk`, or no region), and the nation's for a nation's report. The result + * cannot be matched when an area has no code (an older saved result) or a + * code outside the region's layout (such as a district abolished in April + * 2023, or an area of another nation), or when the region is neither the + * UK nor a nation. + */ +export function withheldUKAreaCodes( + level: UKBoundaryLevel, + codes: (string | null | undefined)[], + region?: string | null +): UKWithheldAreaCodes | null { + const nation = ukNationFromRegion(region); + if (region && region.toLowerCase() !== 'uk' && !nation) { + return null; + } + const regionCodes = Object.keys(UK_HEX_LAYOUTS[level]).filter( + (code) => !nation || ukNationFromCode(code) === nation + ); + const inRegion = new Set(regionCodes); + if (codes.some((code) => !code || !inRegion.has(code))) { + return null; + } + const reportedCodes = new Set(codes); + return { + codes: regionCodes.filter((code) => !reportedCodes.has(code)), + total: regionCodes.length, + }; +} + /** * The areas of a report's region that a result leaves out, as hex-map * areas without a value: each at its layout position, named from the * boundary files, with its ONS code as its id. For a disclosure-controlled * breakdown these are the areas disclosure control withheld. * - * The region's areas are the layout's: all of them for a UK-wide report - * (`uk`, or no region), and the nation's for a nation's report, as - * countWithheldUKAreas counts a nation's against UK_AREA_COUNTS_BY_NATION. - * None are listed when the result cannot be matched to them: an area with - * no code (an older saved result), a code outside the region's layout - * (such as a district abolished in April 2023), or a region that is - * neither the UK nor a nation. So a map that keeps its result's own + * They follow withheldUKAreaCodes, and none are listed when the result + * cannot be matched to the layout. So a map that keeps its result's own * positions, because placeOnUKHexLayout could not place every area, never * gets any: they are drawn only on the tiled grid. */ @@ -99,25 +137,17 @@ export function withheldUKHexAreas( points: Pick[], region?: string | null ): HexMapMissingArea[] { - const nation = ukNationFromRegion(region); - if (region && region.toLowerCase() !== 'uk' && !nation) { - return []; - } - const layout = UK_HEX_LAYOUTS[level]; - const inRegion = (code: string) => - Object.hasOwn(layout, code) && (!nation || ukNationFromCode(code) === nation); - if (points.some((point) => !point.code || !inRegion(point.code))) { - return []; - } - const reportedCodes = new Set(points.map((point) => point.code)); - return Object.keys(layout) - .filter((code) => inRegion(code) && !reportedCodes.has(code)) - .flatMap((code) => { - const position = ukHexPosition(level, code); - return position - ? [{ id: code, label: UK_HEX_AREA_NAMES[level][code] ?? code, ...position }] - : []; - }); + const withheld = withheldUKAreaCodes( + level, + points.map((point) => point.code), + region + ); + return (withheld?.codes ?? []).flatMap((code) => { + const position = ukHexPosition(level, code); + return position + ? [{ id: code, label: UK_HEX_AREA_NAMES[level][code] ?? code, ...position }] + : []; + }); } /** A level's hex map: its result's areas, placed, and the areas drawn as withheld. */ diff --git a/app/src/hooks/useUKLocalAreaOutput.ts b/app/src/hooks/useUKLocalAreaOutput.ts index f62c4c2c5..6f32d0959 100644 --- a/app/src/hooks/useUKLocalAreaOutput.ts +++ b/app/src/hooks/useUKLocalAreaOutput.ts @@ -7,7 +7,6 @@ import { type UKBreakdownWithheldAreas, } from '@/adapters/ukLocalAreaBreakdownAdapter'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import { useRegions } from '@/hooks/useRegions'; import { useUKLocalAreaBreakdown, type UKLocalAreaBreakdownState, @@ -30,9 +29,9 @@ export interface UKLocalAreaOutput { */ areaOutput: SocietyWideReportOutput | null; /** - * The areas withheld at each level the breakdown supplied, counted - * against the UK region metadata, or a nation's areas, where the lists - * allow (countWithheldUKAreas). Saved levels get none. + * The areas withheld at each level the breakdown supplied, counted by + * ONS code against the hex layout's areas of the report's region, as the + * hex map draws them (countWithheldUKAreas). Saved levels get none. */ withheld: UKBreakdownWithheldAreas | undefined; } @@ -50,7 +49,6 @@ export function useUKLocalAreaOutput( const canCompute = !!request && outputServesLocalAreaBreakdowns(output); // An older release's result sends no request, even with a cached run. const breakdown = useUKLocalAreaBreakdown({ countryId: 'uk', ...(canCompute ? request : null) }); - const { data: regions } = useRegions('uk'); const region = request?.region; const areaOutput = useMemo( () => localAreaOutput(output, breakdown.breakdown), @@ -59,12 +57,9 @@ export function useUKLocalAreaOutput( const withheld = useMemo( () => breakdown.breakdown - ? breakdownLevelsWithheld( - output, - withheldUKAreasOf(breakdown.breakdown, region, regions ?? []) - ) + ? breakdownLevelsWithheld(output, withheldUKAreasOf(breakdown.breakdown, region)) : undefined, - [output, breakdown.breakdown, region, regions] + [output, breakdown.breakdown, region] ); return { canCompute: canCompute && breakdown.canStart, breakdown, areaOutput, withheld }; } diff --git a/app/src/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks.ts b/app/src/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks.ts index 620790125..7d48df33a 100644 --- a/app/src/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks.ts +++ b/app/src/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks.ts @@ -1,90 +1,51 @@ -import { fromMetadataRegionEntry, type Region } from '@/models/region'; -import type { MetadataRegionEntry } from '@/types/metadata'; - -/** A local authority in .py 6.10.0's list that the Public API listed only from #4024 on. */ -export const ARDS_AND_NORTH_DOWN = 'Ards and North Down'; - -/** The UK's local authorities in the metadata fixture below, before Ards and North Down. */ -const LOCAL_AUTHORITY_ENTRIES_BEFORE_ARDS_AND_NORTH_DOWN: MetadataRegionEntry[] = [ - { name: 'local_authority/Hartlepool', label: 'Hartlepool', type: 'local_authority' }, - { name: 'local_authority/Middlesbrough', label: 'Middlesbrough', type: 'local_authority' }, -]; - -/** The UK's nations and constituencies as the Public API's metadata gives them, trimmed. */ -const NATION_AND_CONSTITUENCY_ENTRIES: MetadataRegionEntry[] = [ - { name: 'uk', label: 'the UK', type: 'national' }, - { name: 'country/scotland', label: 'Scotland', type: 'country' }, - { - name: 'constituency/East Kilbride and Strathaven', - label: 'East Kilbride and Strathaven', - type: 'constituency', - }, - { name: 'constituency/Aldershot', label: 'Aldershot', type: 'constituency' }, - { name: 'constituency/Aldridge-Brownhills', label: 'Aldridge-Brownhills', type: 'constituency' }, -]; - -const ARDS_AND_NORTH_DOWN_ENTRY: MetadataRegionEntry = { - name: `local_authority/${ARDS_AND_NORTH_DOWN}`, - label: ARDS_AND_NORTH_DOWN, - type: 'local_authority', -}; - /** - * The UK's region list as the Public API's metadata gives it from - * PolicyEngine/policyengine-api#4024 on, trimmed to a few areas: its local - * authorities include Ards and North Down, as .py 6.10.0's 361 do. + * A local authority in .py 6.10.0's list and the layout that the Public + * API's region list names only from PolicyEngine/policyengine-api#4024 on. + * The withheld counts no longer read that list. */ -export const UK_METADATA_REGIONS: Region[] = [ - ...NATION_AND_CONSTITUENCY_ENTRIES, - ...LOCAL_AUTHORITY_ENTRIES_BEFORE_ARDS_AND_NORTH_DOWN, - ARDS_AND_NORTH_DOWN_ENTRY, -].map((entry) => fromMetadataRegionEntry('uk', entry)); +export const ARDS_AND_NORTH_DOWN = 'Ards and North Down'; /** - * The same list from a Public API deployment before #4024, whose 360 local - * authorities leave out Ards and North Down. + * A published local authority name the Public API's region list does not + * use: it names this one "Kingston upon Hull, City of". */ -export const UK_METADATA_REGIONS_WITHOUT_ARDS_AND_NORTH_DOWN: Region[] = [ - ...NATION_AND_CONSTITUENCY_ENTRIES, - ...LOCAL_AUTHORITY_ENTRIES_BEFORE_ARDS_AND_NORTH_DOWN, -].map((entry) => fromMetadataRegionEntry('uk', entry)); - -export const METADATA_CONSTITUENCY_COUNT = 3; -export const METADATA_LOCAL_AUTHORITY_COUNT = 3; - -/** One of the metadata's constituencies, published by a breakdown. */ -export const PUBLISHED_CONSTITUENCY_NAMES = ['East Kilbride and Strathaven']; - -/** One of the metadata's local authorities, published by a breakdown. */ -export const PUBLISHED_LOCAL_AUTHORITY_NAMES = ['Hartlepool']; +export const UNLISTED_LOCAL_AUTHORITY_NAME = 'Kingston upon Hull'; -/** A published name the metadata does not list, so the lists disagree. */ -export const UNLISTED_CONSTITUENCY_NAME = 'Ynys Mon'; +/** The UK's constituencies and local authorities in the hex layouts. */ +export const UK_CONSTITUENCY_COUNT = 650; +export const UK_LOCAL_AUTHORITY_COUNT = 361; /** - * A published local authority name the metadata does not list, as for a - * renamed area: the Public API names this one "Kingston upon Hull, City of". + * The ONS codes of the constituency and the local authority that the + * breakdown fixture (NORMALIZED_BREAKDOWN) publishes: East Kilbride and + * Strathaven, in Scotland, and Hartlepool, in England. */ -export const UNLISTED_LOCAL_AUTHORITY_NAME = 'Kingston upon Hull'; +export const BREAKDOWN_CONSTITUENCY_CODES = ['S14000021']; +export const BREAKDOWN_LOCAL_AUTHORITY_CODES = ['E06000001']; + +/** Ards and North Down's ONS code: in .py 6.10.0's list and the layout. */ +export const ARDS_AND_NORTH_DOWN_CODE = 'N09000011'; + +/** Allerdale's ONS code: a district abolished in April 2023, which the layout lacks. */ +export const PRE_2023_LOCAL_AUTHORITY_CODE = 'E07000026'; -/** The withheld constituencies a UK-wide breakdown of the fixture's areas leaves. */ +/** The withheld constituencies of a UK-wide breakdown that publishes one of the 650. */ export const UK_WIDE_WITHHELD_CONSTITUENCIES = { - withheld: METADATA_CONSTITUENCY_COUNT - PUBLISHED_CONSTITUENCY_NAMES.length, - total: METADATA_CONSTITUENCY_COUNT, + withheld: UK_CONSTITUENCY_COUNT - BREAKDOWN_CONSTITUENCY_CODES.length, + total: UK_CONSTITUENCY_COUNT, }; -/** The withheld local authorities a UK-wide breakdown of the fixture's areas leaves. */ +/** The withheld local authorities of a UK-wide breakdown that publishes one of the 361. */ export const UK_WIDE_WITHHELD_LOCAL_AUTHORITIES = { - withheld: METADATA_LOCAL_AUTHORITY_COUNT - PUBLISHED_LOCAL_AUTHORITY_NAMES.length, - total: METADATA_LOCAL_AUTHORITY_COUNT, + withheld: UK_LOCAL_AUTHORITY_COUNT - BREAKDOWN_LOCAL_AUTHORITY_CODES.length, + total: UK_LOCAL_AUTHORITY_COUNT, }; -/** Wales's constituencies and local authorities in the boundary files. */ +/** Wales's constituencies and local authorities in the layouts. */ export const WALES_CONSTITUENCY_COUNT = 32; export const WALES_LOCAL_AUTHORITY_COUNT = 22; -/** Two Welsh constituencies a Wales breakdown published, by name and ONS code. */ -export const PUBLISHED_WELSH_CONSTITUENCY_NAMES = ['Gower', 'Cardiff East']; +/** Two Welsh constituencies' ONS codes, which a Wales breakdown published. */ export const PUBLISHED_WELSH_CONSTITUENCY_CODES = ['W07000094', 'W07000084']; /** An English constituency's code, which no Wales breakdown publishes. */ @@ -96,5 +57,5 @@ export const WALES_WITHHELD_CONSTITUENCIES = { total: WALES_CONSTITUENCY_COUNT, }; -/** Scotland's constituencies in the boundary files. */ +/** Scotland's constituencies in the layout. */ export const SCOTLAND_CONSTITUENCY_COUNT = 57; diff --git a/app/src/tests/fixtures/data/uk_geography/ukGeographyReference.ts b/app/src/tests/fixtures/data/uk_geography/ukGeographyReference.ts index 94c053653..9051fa9e7 100644 --- a/app/src/tests/fixtures/data/uk_geography/ukGeographyReference.ts +++ b/app/src/tests/fixtures/data/uk_geography/ukGeographyReference.ts @@ -13,6 +13,15 @@ export const PY_LOCAL_AUTHORITIES_CSV = 'local_authorities_2023.csv'; export const EXPECTED_CONSTITUENCY_COUNT = 650; export const EXPECTED_LOCAL_AUTHORITY_COUNT = 361; +/** + * How many of each level's areas each nation has: the totals a nation + * report's withheld note counts against. + */ +export const EXPECTED_AREA_COUNTS_BY_NATION = { + constituency: { england: 543, wales: 32, scotland: 57, northern_ireland: 18 }, + local_authority: { england: 296, wales: 22, scotland: 32, northern_ireland: 11 }, +}; + /** The boundary files the geographic maps load, under app/public. */ export const BOUNDARY_FILE_DIRECTORY = path.resolve( __dirname, diff --git a/app/src/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks.ts b/app/src/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks.ts index 7bdac83ed..5d26efe9b 100644 --- a/app/src/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks.ts +++ b/app/src/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks.ts @@ -1,6 +1,10 @@ import type { LocalAreaBreakdownResponse } from '@/api/societyWideCalculation'; import { EXPECTED_BY_CONSTITUENCY } from '@/tests/fixtures/adapters/societyWideOutputNormalizerMocks'; -import { UNLISTED_LOCAL_AUTHORITY_NAME } from '@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'; +import { + ARDS_AND_NORTH_DOWN, + ARDS_AND_NORTH_DOWN_CODE, + UNLISTED_LOCAL_AUTHORITY_NAME, +} from '@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'; import { BREAKDOWN_ERROR_RESPONSE, BREAKDOWN_REGION_SCOTLAND, @@ -55,8 +59,9 @@ export const COMPLETE_RESPONSE: LocalAreaBreakdownResponse = { }; /** - * A completed breakdown that also publishes a local authority the region - * metadata does not list, so its local authorities cannot be counted. + * A completed breakdown that also publishes a local authority by a name + * the Public API's region list does not use (Kingston upon Hull, which + * the list names "Kingston upon Hull, City of"), with its ONS code. */ export const COMPLETE_RESPONSE_WITH_UNLISTED_LOCAL_AUTHORITY: LocalAreaBreakdownResponse = { status: 'ok', @@ -78,6 +83,37 @@ export const COMPLETE_RESPONSE_WITH_UNLISTED_LOCAL_AUTHORITY: LocalAreaBreakdown }, }; +/** + * A completed UK-wide breakdown that also publishes Ards and North Down, + * which a Public API region list from before PolicyEngine/policyengine-api#4024 + * leaves out. + */ +export const COMPLETE_RESPONSE_WITH_ARDS_AND_NORTH_DOWN: LocalAreaBreakdownResponse = { + status: 'ok', + result: { + ...NORMALIZED_BREAKDOWN, + local_authority_impact: { + by_local_authority: { + ...NORMALIZED_BREAKDOWN.local_authority_impact.by_local_authority, + [ARDS_AND_NORTH_DOWN]: { + x: 13, + y: 39, + average_household_income_change: 95.2, + relative_household_income_change: 0.0031, + code: ARDS_AND_NORTH_DOWN_CODE, + disclosure_tier: 'reported', + }, + }, + }, + }, +}; + +/** + * The local authorities that breakdown leaves out: the UK's 361, less + * Hartlepool and Ards and North Down. + */ +export const WITHHELD_LOCAL_AUTHORITIES_WITH_ARDS_AND_NORTH_DOWN = 359; + export const FAILED_RESPONSE: LocalAreaBreakdownResponse = { status: 'error', result: null, diff --git a/app/src/tests/unit/adapters/ukLocalAreaBreakdownAdapter.test.ts b/app/src/tests/unit/adapters/ukLocalAreaBreakdownAdapter.test.ts index be1e876d8..ea66e7b19 100644 --- a/app/src/tests/unit/adapters/ukLocalAreaBreakdownAdapter.test.ts +++ b/app/src/tests/unit/adapters/ukLocalAreaBreakdownAdapter.test.ts @@ -7,22 +7,19 @@ import { servesLocalAreaBreakdowns, withheldUKAreasOf, } from '@/adapters/ukLocalAreaBreakdownAdapter'; +import { withheldUKHexAreas } from '@/components/visualization/choropleth/ukHexLayout'; import { - ARDS_AND_NORTH_DOWN, + ARDS_AND_NORTH_DOWN_CODE, + BREAKDOWN_CONSTITUENCY_CODES, + BREAKDOWN_LOCAL_AUTHORITY_CODES, ENGLISH_CONSTITUENCY_CODE, - METADATA_CONSTITUENCY_COUNT, - METADATA_LOCAL_AUTHORITY_COUNT, - PUBLISHED_CONSTITUENCY_NAMES, - PUBLISHED_LOCAL_AUTHORITY_NAMES, + PRE_2023_LOCAL_AUTHORITY_CODE, PUBLISHED_WELSH_CONSTITUENCY_CODES, - PUBLISHED_WELSH_CONSTITUENCY_NAMES, SCOTLAND_CONSTITUENCY_COUNT, - UK_METADATA_REGIONS, - UK_METADATA_REGIONS_WITHOUT_ARDS_AND_NORTH_DOWN, + UK_CONSTITUENCY_COUNT, + UK_LOCAL_AUTHORITY_COUNT, UK_WIDE_WITHHELD_CONSTITUENCIES, UK_WIDE_WITHHELD_LOCAL_AUTHORITIES, - UNLISTED_CONSTITUENCY_NAME, - UNLISTED_LOCAL_AUTHORITY_NAME, WALES_LOCAL_AUTHORITY_COUNT, WALES_WITHHELD_CONSTITUENCIES, } from '@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'; @@ -42,20 +39,16 @@ import { MOCK_UK_REPORT_OUTPUT_NO_LA_FIELD } from '@/tests/fixtures/pages/local- import type { LocalAreaBreakdown } from '@/types/metadata/LocalAreaBreakdown'; describe('countWithheldUKAreas', () => { - test('given a UK-wide breakdown then counts the listed constituencies it left out', () => { + test('given a UK-wide breakdown then counts the constituencies it left out, by code, against the layout', () => { // When const result = countWithheldUKAreas({ level: 'constituency', region: 'uk', - publishedNames: PUBLISHED_CONSTITUENCY_NAMES, - regions: UK_METADATA_REGIONS, + publishedCodes: BREAKDOWN_CONSTITUENCY_CODES, }); // Then - expect(result).toEqual({ - withheld: METADATA_CONSTITUENCY_COUNT - PUBLISHED_CONSTITUENCY_NAMES.length, - total: METADATA_CONSTITUENCY_COUNT, - }); + expect(result).toEqual(UK_WIDE_WITHHELD_CONSTITUENCIES); }); test('given a report with no region then it is counted as UK-wide', () => { @@ -63,102 +56,46 @@ describe('countWithheldUKAreas', () => { const result = countWithheldUKAreas({ level: 'constituency', region: undefined, - publishedNames: PUBLISHED_CONSTITUENCY_NAMES, - regions: UK_METADATA_REGIONS, + publishedCodes: BREAKDOWN_CONSTITUENCY_CODES, }); // Then - expect(result?.withheld).toBe(2); + expect(result).toEqual(UK_WIDE_WITHHELD_CONSTITUENCIES); }); - test('given a UK-wide breakdown then counts the listed local authorities it left out, as for constituencies', () => { + test('given a UK-wide breakdown then counts the local authorities it left out, as for constituencies', () => { // When const result = countWithheldUKAreas({ level: 'local_authority', region: 'uk', - publishedNames: PUBLISHED_LOCAL_AUTHORITY_NAMES, - regions: UK_METADATA_REGIONS, + publishedCodes: BREAKDOWN_LOCAL_AUTHORITY_CODES, }); // Then - expect(result).toEqual({ - withheld: METADATA_LOCAL_AUTHORITY_COUNT - PUBLISHED_LOCAL_AUTHORITY_NAMES.length, - total: METADATA_LOCAL_AUTHORITY_COUNT, - }); + expect(result).toEqual(UK_WIDE_WITHHELD_LOCAL_AUTHORITIES); }); - test('given a UK-wide breakdown that publishes Ards and North Down then counts it among the listed local authorities', () => { + test('given a UK-wide breakdown that publishes Ards and North Down then counts it, whatever the region list holds', () => { // When const result = countWithheldUKAreas({ level: 'local_authority', region: 'uk', - publishedNames: [...PUBLISHED_LOCAL_AUTHORITY_NAMES, ARDS_AND_NORTH_DOWN], - regions: UK_METADATA_REGIONS, + publishedCodes: [...BREAKDOWN_LOCAL_AUTHORITY_CODES, ARDS_AND_NORTH_DOWN_CODE], }); // Then - expect(result).toEqual({ withheld: 1, total: METADATA_LOCAL_AUTHORITY_COUNT }); - }); - - test('given a region list without Ards and North Down and a breakdown that publishes it then gives no local authority count', () => { - // When - const result = countWithheldUKAreas({ - level: 'local_authority', - region: 'uk', - publishedNames: [...PUBLISHED_LOCAL_AUTHORITY_NAMES, ARDS_AND_NORTH_DOWN], - regions: UK_METADATA_REGIONS_WITHOUT_ARDS_AND_NORTH_DOWN, + expect(result).toEqual({ + withheld: UK_LOCAL_AUTHORITY_COUNT - 2, + total: UK_LOCAL_AUTHORITY_COUNT, }); - - // Then - expect(result).toBeNull(); }); - test.each([ - ['constituency', PUBLISHED_CONSTITUENCY_NAMES], - ['local_authority', PUBLISHED_LOCAL_AUTHORITY_NAMES], - ] as const)( - 'given a nation report without the published areas’ codes then gives no %s count', - (level, publishedNames) => { - // When - const result = countWithheldUKAreas({ - level, - region: 'country/scotland', - publishedNames, - regions: UK_METADATA_REGIONS, - }); - - // Then - expect(result).toBeNull(); - } - ); - - test.each([ - ['constituency', [...PUBLISHED_CONSTITUENCY_NAMES, UNLISTED_CONSTITUENCY_NAME]], - ['local_authority', [...PUBLISHED_LOCAL_AUTHORITY_NAMES, UNLISTED_LOCAL_AUTHORITY_NAME]], - ] as const)( - 'given a published %s name the metadata does not list then gives no count', - (level, publishedNames) => { - // When - const result = countWithheldUKAreas({ - level, - region: 'uk', - publishedNames: [...publishedNames], - regions: UK_METADATA_REGIONS, - }); - - // Then - expect(result).toBeNull(); - } - ); - test('given a nation report then counts its published areas by code against the nation’s areas', () => { // When const result = countWithheldUKAreas({ level: 'constituency', region: 'country/wales', - publishedNames: PUBLISHED_WELSH_CONSTITUENCY_NAMES, publishedCodes: PUBLISHED_WELSH_CONSTITUENCY_CODES, - regions: UK_METADATA_REGIONS, }); // Then @@ -170,9 +107,7 @@ describe('countWithheldUKAreas', () => { const result = countWithheldUKAreas({ level: 'local_authority', region: 'country/wales', - publishedNames: [], publishedCodes: [], - regions: [], }); // Then @@ -182,52 +117,75 @@ describe('countWithheldUKAreas', () => { }); }); - test('given a nation report that publishes an area of another nation then gives no count', () => { - // When - const result = countWithheldUKAreas({ - level: 'constituency', - region: 'country/wales', - publishedNames: [...PUBLISHED_WELSH_CONSTITUENCY_NAMES, 'Aldershot'], - publishedCodes: [...PUBLISHED_WELSH_CONSTITUENCY_CODES, ENGLISH_CONSTITUENCY_CODE], - regions: UK_METADATA_REGIONS, - }); - - // Then - expect(result).toBeNull(); - }); + test.each([ + [ + 'a nation report that publishes an area of another nation', + 'constituency', + 'country/wales', + [...PUBLISHED_WELSH_CONSTITUENCY_CODES, ENGLISH_CONSTITUENCY_CODE], + ], + [ + 'an area without a code', + 'constituency', + 'country/wales', + [PUBLISHED_WELSH_CONSTITUENCY_CODES[0], undefined], + ], + [ + 'a UK-wide area without a code', + 'local_authority', + 'uk', + [...BREAKDOWN_LOCAL_AUTHORITY_CODES, undefined], + ], + [ + 'a code the layout lacks', + 'local_authority', + 'uk', + [...BREAKDOWN_LOCAL_AUTHORITY_CODES, PRE_2023_LOCAL_AUTHORITY_CODE], + ], + [ + 'a region that is neither the UK nor a nation', + 'constituency', + 'constituency/Aldershot', + BREAKDOWN_CONSTITUENCY_CODES, + ], + ] as const)( + 'given %s then the result cannot be matched by code, and gives no count', + (_, level, region, publishedCodes) => { + // When + const result = countWithheldUKAreas({ level, region, publishedCodes: [...publishedCodes] }); - test('given a nation report that publishes an area without a code then gives no count', () => { - // When - const result = countWithheldUKAreas({ - level: 'constituency', - region: 'country/wales', - publishedNames: PUBLISHED_WELSH_CONSTITUENCY_NAMES, - publishedCodes: [PUBLISHED_WELSH_CONSTITUENCY_CODES[0], undefined], - regions: UK_METADATA_REGIONS, - }); + // Then + expect(result).toBeNull(); + } + ); - // Then - expect(result).toBeNull(); - }); + test.each([ + [ + 'UK-wide local authorities', + 'local_authority', + 'uk', + [...BREAKDOWN_LOCAL_AUTHORITY_CODES, ARDS_AND_NORTH_DOWN_CODE], + ], + ['Welsh constituencies', 'constituency', 'country/wales', PUBLISHED_WELSH_CONSTITUENCY_CODES], + ] as const)( + 'given %s then the count is the number of areas the hex map draws as withheld', + (_, level, region, publishedCodes) => { + // Given + const points = publishedCodes.map((code) => ({ code })); - test('given no region metadata then gives no count', () => { - // When - const result = countWithheldUKAreas({ - level: 'constituency', - region: 'uk', - publishedNames: PUBLISHED_CONSTITUENCY_NAMES, - regions: [], - }); + // When + const result = countWithheldUKAreas({ level, region, publishedCodes: [...publishedCodes] }); - // Then - expect(result).toBeNull(); - }); + // Then + expect(result?.withheld).toBe(withheldUKHexAreas(level, points, region).length); + } + ); }); describe('withheldUKAreasOf', () => { test('given a UK-wide breakdown then counts constituencies and local authorities by the same rule', () => { // When - const result = withheldUKAreasOf(NORMALIZED_BREAKDOWN, 'uk', UK_METADATA_REGIONS); + const result = withheldUKAreasOf(NORMALIZED_BREAKDOWN, 'uk'); // Then expect(result).toEqual({ @@ -236,23 +194,23 @@ describe('withheldUKAreasOf', () => { }); }); - test('given a breakdown that publishes an unlisted local authority then only the local authorities lose their count', () => { + test('given a breakdown that publishes a local authority under a name the region list lacks then counts it by its code', () => { // Given const breakdown = COMPLETE_RESPONSE_WITH_UNLISTED_LOCAL_AUTHORITY.result as LocalAreaBreakdown; // When - const result = withheldUKAreasOf(breakdown, 'uk', UK_METADATA_REGIONS); + const result = withheldUKAreasOf(breakdown, 'uk'); // Then expect(result).toEqual({ constituency: UK_WIDE_WITHHELD_CONSTITUENCIES, - local_authority: null, + local_authority: { withheld: UK_LOCAL_AUTHORITY_COUNT - 2, total: UK_LOCAL_AUTHORITY_COUNT }, }); }); test('given a Scotland report then its Scottish constituency is counted and an English local authority leaves its level uncounted', () => { // When - const result = withheldUKAreasOf(NORMALIZED_BREAKDOWN, 'country/scotland', UK_METADATA_REGIONS); + const result = withheldUKAreasOf(NORMALIZED_BREAKDOWN, 'country/scotland'); // Then expect(result).toEqual({ @@ -269,10 +227,13 @@ describe('withheldUKAreasOf', () => { const breakdown = { ...NORMALIZED_BREAKDOWN, constituency_impact: null } as any; // When - const result = withheldUKAreasOf(breakdown, 'uk', UK_METADATA_REGIONS); + const result = withheldUKAreasOf(breakdown, 'uk'); // Then - expect(result.constituency).toEqual({ withheld: 3, total: METADATA_CONSTITUENCY_COUNT }); + expect(result.constituency).toEqual({ + withheld: UK_CONSTITUENCY_COUNT, + total: UK_CONSTITUENCY_COUNT, + }); expect(result.local_authority).toEqual(UK_WIDE_WITHHELD_LOCAL_AUTHORITIES); }); }); diff --git a/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx b/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx index ceb3a8737..c701d3266 100644 --- a/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx +++ b/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx @@ -26,9 +26,11 @@ import { } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; import { COMPLETE_RESPONSE, + COMPLETE_RESPONSE_WITH_ARDS_AND_NORTH_DOWN, COMPLETE_RESPONSE_WITH_UNLISTED_LOCAL_AUTHORITY, FAILED_RESPONSE, QUEUED_RESPONSE, + WITHHELD_LOCAL_AUTHORITIES_WITH_ARDS_AND_NORTH_DOWN, } from '@/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks'; vi.mock('@/api/societyWideCalculation', async (importOriginal) => ({ @@ -36,12 +38,6 @@ vi.mock('@/api/societyWideCalculation', async (importOriginal) => ({ fetchLocalAreaBreakdown: vi.fn(), })); -vi.mock('@/hooks/useRegions', async () => { - const { UK_METADATA_REGIONS } = - await import('@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'); - return { useRegions: () => ({ data: UK_METADATA_REGIONS }) }; -}); - const mockFetchLocalAreaBreakdown = vi.mocked(fetchLocalAreaBreakdown); function renderSection(output = NATIONAL_OUTPUT_WITHOUT_AREAS, props = UK_SECTION_PROPS) { @@ -162,7 +158,7 @@ describe('UKLocalAreaSection', () => { // Then expect( - await screen.findByText(/^2 of 3 constituencies are not reported\./) + await screen.findByText(/^649 of 650 constituencies are not reported\./) ).toBeInTheDocument(); }); @@ -173,24 +169,26 @@ describe('UKLocalAreaSection', () => { renderSection(); await user.click(screen.getByRole('button', { name: COMPUTE_BUTTON_LABEL })); expect( - await screen.findByText(/^2 of 3 constituencies are not reported\./) + await screen.findByText(/^649 of 650 constituencies are not reported\./) ).toBeInTheDocument(); // When await user.click(screen.getByRole('tab', { name: 'Local authorities' })); // Then - expect(screen.getByText(/^2 of 3 local authorities are not reported\./)).toBeInTheDocument(); + expect( + screen.getByText(/^360 of 361 local authorities are not reported\./) + ).toBeInTheDocument(); }); - test('given a breakdown that publishes a local authority the region list does not name then that map has no count and the constituency map keeps its own', async () => { + test('given a breakdown that publishes a local authority under a name the region list lacks then that map counts it by its code', async () => { // Given const user = userEvent.setup(); mockFetchLocalAreaBreakdown.mockResolvedValue(COMPLETE_RESPONSE_WITH_UNLISTED_LOCAL_AUTHORITY); renderSection(); await user.click(screen.getByRole('button', { name: COMPUTE_BUTTON_LABEL })); expect( - await screen.findByText(/^2 of 3 constituencies are not reported\./) + await screen.findByText(/^649 of 650 constituencies are not reported\./) ).toBeInTheDocument(); // When @@ -198,9 +196,27 @@ describe('UKLocalAreaSection', () => { // Then expect( - screen.getByText(/^Local authorities whose estimates fail the survey data’s disclosure rules/) + screen.getByText(/^359 of 361 local authorities are not reported\./) + ).toBeInTheDocument(); + }); + + test('given a UK-wide breakdown that publishes Ards and North Down then the note counts the local authorities the hex map draws as withheld', async () => { + // Given: Ards and North Down, which a region list before PolicyEngine/policyengine-api#4024 lacks + const user = userEvent.setup(); + mockFetchLocalAreaBreakdown.mockResolvedValue(COMPLETE_RESPONSE_WITH_ARDS_AND_NORTH_DOWN); + const { container } = renderSection(); + await user.click(screen.getByRole('button', { name: COMPUTE_BUTTON_LABEL })); + await user.click(await screen.findByRole('tab', { name: 'Local authorities' })); + + // When + await user.click(screen.getByRole('tab', { name: HEX_MAP_TAB })); + + // Then + const hatched = container.querySelectorAll('[data-missing-area-id]').length; + expect(hatched).toBe(WITHHELD_LOCAL_AUTHORITIES_WITH_ARDS_AND_NORTH_DOWN); + expect( + screen.getByText(new RegExp(`^${hatched} of 361 local authorities are not reported\\.`)) ).toBeInTheDocument(); - expect(screen.queryByText(/local authorities are not reported\./)).not.toBeInTheDocument(); }); test('given saved area data then no disclosure note shows', async () => { diff --git a/app/src/tests/unit/data/ukBoundaryFiles.test.ts b/app/src/tests/unit/data/ukBoundaryFiles.test.ts index 613fdec51..3244ee98a 100644 --- a/app/src/tests/unit/data/ukBoundaryFiles.test.ts +++ b/app/src/tests/unit/data/ukBoundaryFiles.test.ts @@ -1,10 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { describe, expect, test } from 'vitest'; -import { - UK_AREA_COUNTS_BY_NATION, - ukNationFromCode, -} from '@/components/visualization/choropleth/ukAreaBoundaries'; +import { ukNationFromCode } from '@/components/visualization/choropleth/ukAreaBoundaries'; import { UK_HEX_AREA_NAMES, UK_HEX_LAYOUTS, @@ -12,6 +9,7 @@ import { import { BOUNDARY_FILE_DIRECTORY, CONSTITUENCY_BOUNDARY_FILE, + EXPECTED_AREA_COUNTS_BY_NATION, EXPECTED_CONSTITUENCY_COUNT, EXPECTED_LOCAL_AUTHORITY_COUNT, LOCAL_AUTHORITY_BOUNDARY_FILE, @@ -67,18 +65,18 @@ const LEVELS = [ describe.each(LEVELS)( 'UK $level boundary file', ({ boundaryLevel, boundaryFile, referenceFile, codeProperty, nameProperty, expectedCount }) => { - test("given the file's codes then each nation has the area count the withheld notes use", () => { + test("given the hex layout's codes then each nation has the area count the withheld notes use", () => { // Given const counts: Record = {}; // When - for (const feature of readBoundaries(boundaryFile)) { - const nation = ukNationFromCode(feature.properties[codeProperty]) ?? 'none'; + for (const code of Object.keys(UK_HEX_LAYOUTS[boundaryLevel])) { + const nation = ukNationFromCode(code) ?? 'none'; counts[nation] = (counts[nation] ?? 0) + 1; } // Then - expect(counts).toEqual(UK_AREA_COUNTS_BY_NATION[boundaryLevel]); + expect(counts).toEqual(EXPECTED_AREA_COUNTS_BY_NATION[boundaryLevel]); }); test(`given the hex layout then it places exactly the file's ${expectedCount} codes, each on its own hex`, () => { diff --git a/app/src/tests/unit/hooks/useUKLocalAreaOutput.test.tsx b/app/src/tests/unit/hooks/useUKLocalAreaOutput.test.tsx index c26eb545b..9c4bf798e 100644 --- a/app/src/tests/unit/hooks/useUKLocalAreaOutput.test.tsx +++ b/app/src/tests/unit/hooks/useUKLocalAreaOutput.test.tsx @@ -21,12 +21,6 @@ vi.mock('@/api/societyWideCalculation', async (importOriginal) => ({ fetchLocalAreaBreakdown: vi.fn(), })); -vi.mock('@/hooks/useRegions', async () => { - const { UK_METADATA_REGIONS } = - await import('@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'); - return { useRegions: () => ({ data: UK_METADATA_REGIONS }) }; -}); - const mockFetchLocalAreaBreakdown = vi.mocked(fetchLocalAreaBreakdown); function wrapper({ children }: { children: ReactNode }) { diff --git a/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx b/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx index ad8055e63..66160426f 100644 --- a/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/ConstituencySubPage.test.tsx @@ -28,12 +28,6 @@ vi.mock('@/api/societyWideCalculation', async (importOriginal) => ({ fetchLocalAreaBreakdown: vi.fn(), })); -vi.mock('@/hooks/useRegions', async () => { - const { UK_METADATA_REGIONS } = - await import('@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'); - return { useRegions: () => ({ data: UK_METADATA_REGIONS }) }; -}); - const mockFetchLocalAreaBreakdown = vi.mocked(fetchLocalAreaBreakdown); const NOT_AVAILABLE = 'Constituency analysis not available for this region'; @@ -133,7 +127,7 @@ describe('ConstituencySubPage', () => { // Then expect( - await screen.findByText(/^2 of 3 constituencies are not reported\./) + await screen.findByText(/^649 of 650 constituencies are not reported\./) ).toBeInTheDocument(); expect(screen.getByText(/^1 constituency is approximate:/)).toBeInTheDocument(); }); diff --git a/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx b/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx index 7b6ccbeef..6f0ad3586 100644 --- a/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx @@ -21,8 +21,10 @@ import { import { EXPECTED_LOCAL_AUTHORITY_COUNT } from '@/tests/fixtures/data/uk_geography/ukGeographyReference'; import { COMPLETE_RESPONSE, + COMPLETE_RESPONSE_WITH_ARDS_AND_NORTH_DOWN, COMPLETE_RESPONSE_WITH_UNLISTED_LOCAL_AUTHORITY, FAILED_RESPONSE, + WITHHELD_LOCAL_AUTHORITIES_WITH_ARDS_AND_NORTH_DOWN, } from '@/tests/fixtures/hooks/useUKLocalAreaBreakdownMocks'; import { MOCK_UK_REPORT_OUTPUT_NO_LA_FIELD, @@ -38,12 +40,6 @@ vi.mock('@/api/societyWideCalculation', async (importOriginal) => ({ fetchLocalAreaBreakdown: vi.fn(), })); -vi.mock('@/hooks/useRegions', async () => { - const { UK_METADATA_REGIONS } = - await import('@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'); - return { useRegions: () => ({ data: UK_METADATA_REGIONS }) }; -}); - const mockFetchLocalAreaBreakdown = vi.mocked(fetchLocalAreaBreakdown); function renderWithQueryClient(ui: React.ReactNode) { @@ -187,11 +183,11 @@ describe('LocalAuthoritySubPage', () => { // Then expect( - await screen.findByText(/^2 of 3 local authorities are not reported\./) + await screen.findByText(/^360 of 361 local authorities are not reported\./) ).toBeInTheDocument(); }); - test('given a computed local authority the region list does not name then the charts say local authorities may be withheld, with no count', async () => { + test('given a computed local authority under a name the region list lacks then the charts count it by its code', async () => { // Given const user = userEvent.setup(); mockFetchLocalAreaBreakdown.mockResolvedValue( @@ -209,9 +205,31 @@ describe('LocalAuthoritySubPage', () => { // Then expect( - await screen.findByText( - /^Local authorities whose estimates fail the survey data’s disclosure rules are not reported\./ - ) + await screen.findByText(/^359 of 361 local authorities are not reported\./) + ).toBeInTheDocument(); + }); + + test('given Ards and North Down computed for a UK-wide report then the note counts the local authorities the hex map draws as withheld', async () => { + // Given: Ards and North Down, which a region list before PolicyEngine/policyengine-api#4024 lacks + const user = userEvent.setup(); + mockFetchLocalAreaBreakdown.mockResolvedValue(COMPLETE_RESPONSE_WITH_ARDS_AND_NORTH_DOWN); + const { container } = renderWithQueryClient( + + ); + await user.click(screen.getByRole('button', { name: COMPUTE_BUTTON_LABEL })); + await screen.findByText('Absolute household income change by local authority'); + + // When + await user.click(screen.getByRole('tab', { name: HEX_MAP_TAB })); + + // Then + const hatched = container.querySelectorAll('[data-missing-area-id]').length; + expect(hatched).toBe(WITHHELD_LOCAL_AUTHORITIES_WITH_ARDS_AND_NORTH_DOWN); + expect( + screen.getByText(new RegExp(`^${hatched} of 361 local authorities are not reported\\.`)) ).toBeInTheDocument(); }); diff --git a/app/src/tests/unit/pages/report-output/MigrationSubPage.test.tsx b/app/src/tests/unit/pages/report-output/MigrationSubPage.test.tsx index de65dc6c1..34f24a5db 100644 --- a/app/src/tests/unit/pages/report-output/MigrationSubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/MigrationSubPage.test.tsx @@ -54,12 +54,6 @@ vi.mock('@/hooks/useCurrentCountry', () => ({ useCurrentCountry: mockUseCurrentCountry, })); -vi.mock('@/hooks/useRegions', async () => { - const { UK_METADATA_REGIONS } = - await import('@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'); - return { useRegions: () => ({ data: UK_METADATA_REGIONS }) }; -}); - vi.mock('@/api/societyWideCalculation', async (importOriginal) => ({ ...(await importOriginal()), fetchLocalAreaBreakdown: vi.fn(), From e4e754d9631100838b3ec5bb2ba6a3bfbb54527c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:34:08 +0400 Subject: [PATCH 17/21] Space a withheld hexagon's stripes at least 4 screen pixels on a phone The hatching's minimum spacing was 4 SVG units, which is 4 pixels only when one unit is one pixel. v2's hex charts draw a fixed 600-unit viewBox, so at 375 pixels wide one unit is 0.625 pixels and the UK-wide constituency map's stripes sat 2.5 pixels apart, blending into a flat grey on the scale's grey arm. HexagonalMap now measures its rendered width whenever it has missing areas and sets the minimum in screen pixels: 4 divided by the screen pixels per unit, which is the rendered width over the viewBox width, never more than one, since the SVG is as tall as its viewBox. Co-Authored-By: Claude Opus 5.5 --- .../components/visualization/HexagonalMap.tsx | 20 +++++--- .../components/visualization/hexMapMocks.ts | 40 ++++++++++++++++ .../visualization/HexagonalMap.test.tsx | 26 +++++++++++ .../AbsoluteChangeByConstituency.test.tsx | 46 ++++++++++++++++++- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/app/src/components/visualization/HexagonalMap.tsx b/app/src/components/visualization/HexagonalMap.tsx index cc11f7fde..7046d90e1 100644 --- a/app/src/components/visualization/HexagonalMap.tsx +++ b/app/src/components/visualization/HexagonalMap.tsx @@ -305,16 +305,17 @@ const DEFAULT_MISSING_AREA_TEXT = 'No data'; * A missing area's look: grey stripes on the map's background, which no * colour on the scale resembles, inside a grey edge where a valued * hexagon's edge is the background colour. The stripes repeat at half a - * hexagon's radius, but never closer than 4 SVG units (4 pixels on a map - * drawn at full width): closer, they blend into a flat grey that the - * scale's grey arm holds, as on the UK constituency map. + * hexagon's radius, but never closer than 4 pixels on screen: closer, they + * blend into a flat grey that the scale's grey arm holds, as on the UK + * constituency map, or on a fixed-width map drawn narrower than its 600 + * units, as on a phone. */ const MISSING_AREA_GROUND_COLOR = colors.background.primary; const MISSING_AREA_STRIPE_COLOR = colors.gray[300]; const MISSING_AREA_EDGE_COLOR = colors.gray[400]; const MISSING_AREA_EDGE_WIDTH = 0.75; const MISSING_AREA_STRIPES_PER_RADIUS = 2; -const MIN_MISSING_AREA_STRIPE_SPACING = 4; +const MIN_MISSING_AREA_STRIPE_PIXELS = 4; /** * SVG polygon points for a hexagon centred at (cx, cy): flat-topped, or @@ -507,8 +508,13 @@ export function HexagonalMap({ const colorBarSpace = fullConfig.showColorBar ? COLOR_BAR_MARGIN + COLOR_BAR_WIDTH + 40 : 0; // With fillWidth, one viewBox unit is one CSS pixel, as the SVG's height is `height` pixels. const [svgElement, setSvgElement] = useState(null); - const measuredWidth = useElementWidth(svgElement, fillWidth); - const svgWidth = measuredWidth ?? FIXED_SVG_WIDTH; // Fixed width, responsive via CSS + // The rendered width sets a fillWidth map's width, and tells a map with + // missing areas how many screen pixels one SVG unit takes. + const renderedWidth = useElementWidth(svgElement, fillWidth || missingAreas.length > 0); + const svgWidth = (fillWidth ? renderedWidth : null) ?? FIXED_SVG_WIDTH; // Fixed width, responsive via CSS + // The SVG is `height` pixels tall, as its viewBox is, so the viewBox scales + // to fit the narrower of the two: never past one pixel per unit. + const screenPixelsPerUnit = renderedWidth ? Math.min(renderedWidth / svgWidth, 1) : 1; const dataWidth = dataBounds.xMax - dataBounds.xMin || 1; const dataHeight = dataBounds.yMax - dataBounds.yMin || 1; @@ -522,7 +528,7 @@ export function HexagonalMap({ ? scale * (fullConfig.coordinateScale ?? 1) * TILED_HEX_RADIUS : Math.min(hexSize * (scale / 20), scale * 0.45); const stripeSpacing = Math.max( - MIN_MISSING_AREA_STRIPE_SPACING, + MIN_MISSING_AREA_STRIPE_PIXELS / screenPixelsPerUnit, hexPixelSize / MISSING_AREA_STRIPES_PER_RADIUS ); diff --git a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts index fb3efbb4d..85f43a88d 100644 --- a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts +++ b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest'; import { colors } from '@/designTokens'; import type { HexMapDataPoint, HexMapMissingArea } from '@/types/visualization/HexMapDataPoint'; @@ -120,3 +121,42 @@ export const MIN_MISSING_AREA_STRIPE_PIXELS = 4; /** A missing area's edge, where a valued hexagon's is the background colour. */ export const MISSING_AREA_EDGE_COLOR = colors.gray[400]; + +/** A phone's width, in CSS pixels: what a v2 hex chart is drawn at on one. */ +export const PHONE_WIDTH_PIXELS = 375; + +/** + * Makes every SVG element measure `width` CSS pixels wide, as the hex map + * reads its rendered width, with a ResizeObserver that never reports (the + * map measures once on mount). Returns a function that undoes both. + */ +export function mockRenderedSvgWidth(width: number): () => void { + const originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + const widthSpy = vi + .spyOn(SVGElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ width } as DOMRect); + return () => { + window.ResizeObserver = originalResizeObserver; + widthSpy.mockRestore(); + }; +} + +/** + * How far apart a missing area's stripes sit on screen, in CSS pixels: the + * hatching pattern's spacing, in SVG units, times the screen pixels per + * unit. The SVG is `width` pixels wide and as tall as its viewBox, so the + * viewBox scales to fit the narrower of the two, and never past its height. + */ +export function stripeSpacingOnScreen(container: HTMLElement, width: number): number { + const pattern = container.querySelector('pattern')!; + const svg = pattern.ownerSVGElement!; + const [, , viewBoxWidth] = svg.getAttribute('viewBox')!.split(' ').map(Number); + const zoomLayer = svg.querySelector('[data-testid="hex-map-zoom-layer"]'); + const zoom = Number(zoomLayer?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ?? 1); + return Number(pattern.getAttribute('width')) * Math.min(width / viewBoxWidth, 1) * zoom; +} diff --git a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx index b36d85847..987ec9d9d 100644 --- a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx +++ b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx @@ -13,7 +13,10 @@ import { MOCK_POSITIVE_VALUES, MOCK_TILED_HEX_DATA, MOCK_UK_SHAPED_HEX_DATA, + mockRenderedSvgWidth, NOT_REPORTED_TEXT, + PHONE_WIDTH_PIXELS, + stripeSpacingOnScreen, } from '@/tests/fixtures/components/visualization/hexMapMocks'; /** A hexagon polygon's vertices, in drawing order. */ @@ -727,6 +730,29 @@ describe('HexagonalMap missing areas', () => { expect(missingEdge).not.toBe(valuedEdge); }); + test('given a fixed-width map drawn on a phone then the stripes are at least 4 screen pixels apart', () => { + // Given: the 600-unit map drawn 375 pixels wide, so one unit is 0.625 pixels + const restoreWidth = mockRenderedSvgWidth(PHONE_WIDTH_PIXELS); + + try { + // When + const { container } = render( + + ); + + // Then + expect(stripeSpacingOnScreen(container, PHONE_WIDTH_PIXELS)).toBeGreaterThanOrEqual( + MIN_MISSING_AREA_STRIPE_PIXELS + ); + } finally { + restoreWidth(); + } + }); + test('given no missing areas then the map draws no hatching', () => { // When const { container } = render(); diff --git a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx index f049cefe2..bbc5dd1b2 100644 --- a/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx +++ b/app/src/tests/unit/pages/report-output/constituency/AbsoluteChangeByConstituency.test.tsx @@ -2,6 +2,7 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { AbsoluteChangeByConstituency } from '@/pages/report-output/constituency/AbsoluteChangeByConstituency'; +import { UK_WIDE_WITHHELD_CONSTITUENCIES } from '@/tests/fixtures/adapters/ukLocalAreaBreakdownAdapterMocks'; import { UK_LOCAL_AREAS_OUTPUT, UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED, @@ -22,6 +23,12 @@ import { CARDIFF_EAST_NAME, WELSH_CONSTITUENCY_COUNT, } from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; +import { + MIN_MISSING_AREA_STRIPE_PIXELS, + mockRenderedSvgWidth, + PHONE_WIDTH_PIXELS, + stripeSpacingOnScreen, +} from '@/tests/fixtures/components/visualization/hexMapMocks'; import { MOCK_UK_REPORT_OUTPUT, MOCK_UK_REPORT_OUTPUT_NO_CONSTITUENCY, @@ -33,7 +40,11 @@ import { v2Hexagons, v3HexagonArrangement, } from '@/tests/fixtures/pages/report-output/ukHexMapPlacementMocks'; -import { withNullAreaImpacts } from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; +import { + BREAKDOWN_CONSTITUENCY_IMPACT, + V2_UK_REGION, + withNullAreaImpacts, +} from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; beforeEach(() => { vi.stubGlobal('fetch', mockUKBoundaryFetch()); @@ -313,3 +324,36 @@ describe('AbsoluteChangeByConstituency withheld areas on the hex map', () => { expect(withheldHexagons(container).length).toBe(0); }); }); + +describe('AbsoluteChangeByConstituency withheld hatching on a phone', () => { + test('given a UK-wide breakdown drawn 375 pixels wide then the withheld hatching’s stripes are at least 4 screen pixels apart', async () => { + // Given: the chart's 600-unit hex map measures a phone's width + const restoreWidth = mockRenderedSvgWidth(PHONE_WIDTH_PIXELS); + const output = { + ...NATIONAL_OUTPUT_WITHOUT_AREAS, + constituency_impact: BREAKDOWN_CONSTITUENCY_IMPACT, + } as SocietyWideReportOutput; + + try { + const user = userEvent.setup(); + const { container } = render( + + ); + await screen.findByRole('img', { name: MAP_LEGEND_NAME }); + + // When + await user.click(screen.getByRole('tab', { name: 'Hex grid' })); + + // Then + expect(stripeSpacingOnScreen(container, PHONE_WIDTH_PIXELS)).toBeGreaterThanOrEqual( + MIN_MISSING_AREA_STRIPE_PIXELS + ); + } finally { + restoreWidth(); + } + }); +}); From 3fbc1ec96521b8ac5005c618f8db908ae02bf0bb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:35:06 +0400 Subject: [PATCH 18/21] Keep a withheld hexagon's stripes the same on screen at any zoom The hatching pattern sits inside v3's zoomed layer, in that layer's user units, so zooming scaled the stripes with the map: at zoom 20 they sat 80 pixels apart where they sat 4 at zoom 1, and a withheld hexagon showed one or two wide bands. The pattern's size is now divided by the zoom factor, so the stripes keep their spacing on screen at every zoom level. Co-Authored-By: Claude Opus 5.5 --- .../components/visualization/HexagonalMap.tsx | 14 ++++++---- .../components/visualization/hexMapMocks.ts | 3 +++ .../visualization/HexagonalMap.test.tsx | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/app/src/components/visualization/HexagonalMap.tsx b/app/src/components/visualization/HexagonalMap.tsx index 7046d90e1..e12417d39 100644 --- a/app/src/components/visualization/HexagonalMap.tsx +++ b/app/src/components/visualization/HexagonalMap.tsx @@ -565,6 +565,10 @@ export function HexagonalMap({ ); // The selected outline keeps its width in screen pixels at any zoom. const outlineEffect = zoomable ? 'non-scaling-stroke' : undefined; + // The hatching is drawn inside the zoomed layer, which scales it by the + // zoom; dividing by the zoom keeps the stripes as far apart on screen at + // every zoom level. + const patternSpacing = stripeSpacing / zoom.view.k; const showTooltip = (event: React.MouseEvent, text: string) => { const rect = (event.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect(); @@ -579,15 +583,15 @@ export function HexagonalMap({ - + diff --git a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts index 85f43a88d..fa106fbda 100644 --- a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts +++ b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts @@ -160,3 +160,6 @@ export function stripeSpacingOnScreen(container: HTMLElement, width: number): nu const zoom = Number(zoomLayer?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ?? 1); return Number(pattern.getAttribute('width')) * Math.min(width / viewBoxWidth, 1) * zoom; } + +/** The hex map's viewBox width when it does not fill its frame, and so its width in jsdom. */ +export const FIXED_MAP_WIDTH = 600; diff --git a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx index 987ec9d9d..681760eec 100644 --- a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx +++ b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, userEvent } from '@test-utils'; import { describe, expect, test, vi } from 'vitest'; import { HexagonalMap, zoomAbout } from '@/components/visualization/HexagonalMap'; import { + FIXED_MAP_WIDTH, MIN_MISSING_AREA_STRIPE_PIXELS, MISSING_AREA_EDGE_COLOR, MISSING_FAR_EAST, @@ -753,6 +754,31 @@ describe('HexagonalMap missing areas', () => { } }); + test('given a zoomable map zoomed from 1 to 20 then the stripes keep their spacing on screen', async () => { + // Given + const user = userEvent.setup(); + const { container } = render( + + ); + const spacingAtZoomOne = stripeSpacingOnScreen(container, FIXED_MAP_WIDTH); + + // When: eight steps of 1.5 reach the zoom limit, 20 + for (let step = 0; step < 8; step++) { + await user.click(screen.getByRole('button', { name: 'Zoom in' })); + } + + // Then + expect(screen.getByTestId('hex-map-zoom-layer').getAttribute('transform')).toContain( + 'scale(20)' + ); + expect(stripeSpacingOnScreen(container, FIXED_MAP_WIDTH)).toBeCloseTo(spacingAtZoomOne, 6); + }); + test('given no missing areas then the map draws no hatching', () => { // When const { container } = render(); From 4a674688abaff42269df1781e5a05cbadc83e93d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:35:52 +0400 Subject: [PATCH 19/21] Check the hex map's area names against .py's area lists A withheld hexagon is named from the name files beside the layouts, while every valued area, the rankings and the finder take their names from the results, which come from .py's area lists. A test now checks that each name file equals the app's copy of .py 6.10.0's list (constituencies_2024.csv, local_authorities_2023.csv), code by code. It passes today; run against copies with one name changed in each file, it failed and named both differences. Co-Authored-By: Claude Opus 5.5 --- app/src/tests/unit/data/ukBoundaryFiles.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/tests/unit/data/ukBoundaryFiles.test.ts b/app/src/tests/unit/data/ukBoundaryFiles.test.ts index 3244ee98a..2f8a666b5 100644 --- a/app/src/tests/unit/data/ukBoundaryFiles.test.ts +++ b/app/src/tests/unit/data/ukBoundaryFiles.test.ts @@ -143,6 +143,19 @@ describe.each(LEVELS)( expect(mismatches).toEqual([]); }); + test("given .py 6.10.0's list then the hex map's area names equal its names, code by code", () => { + // Given: withheld hexagons are named from these files, every other area from the results + const referenceNames = Object.fromEntries( + readPyAreaList(referenceFile).map((area) => [area.code, area.name]) + ); + + // When + const names = UK_HEX_AREA_NAMES[boundaryLevel]; + + // Then + expect(names).toEqual(referenceNames); + }); + test('given the file then it is simplified, under 2 MB', () => { // When const bytes = fs.statSync(path.join(BOUNDARY_FILE_DIRECTORY, boundaryFile)).size; From 4203dfd750b2aad4f5aff96c7f4e8b8563a05d73 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:36:32 +0400 Subject: [PATCH 20/21] Drop withheldUKHexAreas' position guard, which cannot fail Each withheld code is one of the layout's own keys, so it always has a position; the null check around ukHexPosition could never take its other branch. withheldUKHexAreas now reads the position from the layout directly, through gridPosition, which ukHexPosition shares. A refactor: the existing tests pass unchanged. Co-Authored-By: Claude Opus 5.5 --- .../visualization/choropleth/ukHexLayout.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/components/visualization/choropleth/ukHexLayout.ts b/app/src/components/visualization/choropleth/ukHexLayout.ts index c54f6809d..cc543f8cf 100644 --- a/app/src/components/visualization/choropleth/ukHexLayout.ts +++ b/app/src/components/visualization/choropleth/ukHexLayout.ts @@ -43,7 +43,12 @@ export function ukHexPosition( code?: string | null ): { x: number; y: number } | null { const position = code ? UK_HEX_LAYOUTS[level][code] : undefined; - return position ? { x: position[0], y: position[1] + 1 } : null; + return position ? gridPosition(position) : null; +} + +/** A layout's `[q, r]` position on the hex map's grid: `y = r + 1`, as ukHexPosition explains. */ +function gridPosition([q, r]: number[]): { x: number; y: number } { + return { x: q, y: r + 1 }; } /** A level's hex-map points placed on its layout, and whether the map tiles them. */ @@ -142,12 +147,12 @@ export function withheldUKHexAreas( points.map((point) => point.code), region ); - return (withheld?.codes ?? []).flatMap((code) => { - const position = ukHexPosition(level, code); - return position - ? [{ id: code, label: UK_HEX_AREA_NAMES[level][code] ?? code, ...position }] - : []; - }); + // Each withheld code is one of the layout's own keys, so it has a position. + return (withheld?.codes ?? []).map((code) => ({ + id: code, + label: UK_HEX_AREA_NAMES[level][code] ?? code, + ...gridPosition(UK_HEX_LAYOUTS[level][code]), + })); } /** A level's hex map: its result's areas, placed, and the areas drawn as withheld. */ From 867af2c19a34ae0e091cf7974f99c909fa1208ea Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:36:53 +0400 Subject: [PATCH 21/21] Say that hex-map coordinates can be negative, not 0-based HexMapDataPoint's and HexMapMissingArea's x and y were documented as 0-based, but layout positions can be negative, as most of the UK constituency layout's rows are. Documentation only. Co-Authored-By: Claude Opus 5.5 --- app/src/types/visualization/HexMapDataPoint.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/types/visualization/HexMapDataPoint.ts b/app/src/types/visualization/HexMapDataPoint.ts index 7c4f92bdd..4834a609e 100644 --- a/app/src/types/visualization/HexMapDataPoint.ts +++ b/app/src/types/visualization/HexMapDataPoint.ts @@ -25,10 +25,10 @@ export interface HexMapDataPoint { /** The value to visualize (determines color) */ value: number; - /** X-coordinate on the hexagonal grid (0-based) */ + /** X-coordinate on the hexagonal grid: a column, which can be negative */ x: number; - /** Y-coordinate on the hexagonal grid (0-based) */ + /** Y-coordinate on the hexagonal grid: a row, which can be negative */ y: number; /** The area's code, e.g. a UK area's ONS code, when the data gives one. */ @@ -54,9 +54,9 @@ export interface HexMapMissingArea { /** Display label (e.g., constituency name) */ label: string; - /** X-coordinate on the hexagonal grid (0-based) */ + /** X-coordinate on the hexagonal grid: a column, which can be negative */ x: number; - /** Y-coordinate on the hexagonal grid (0-based) */ + /** Y-coordinate on the hexagonal grid: a row, which can be negative */ y: number; }