diff --git a/app/src/adapters/ukLocalAreaBreakdownAdapter.ts b/app/src/adapters/ukLocalAreaBreakdownAdapter.ts index 550eec09e..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'; @@ -126,6 +121,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. @@ -136,60 +137,26 @@ 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 - * 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 - * from the country metadata, by name. The count needs every published - * name to be in the list; otherwise the lists disagree, and no count is - * given. The Public API lists the same 650 constituencies as .py, and - * from PolicyEngine/policyengine-api#4024 on the same 361 local - * authorities as .py 6.10.0. An earlier deployment lists 360, without - * Ards and North Down; a breakdown that publishes that area then gets no - * local authority count. - * - A nation report counts against the nation's areas in the boundary files - * (UK_AREA_COUNTS_BY_NATION), by each published area's ONS code, because - * the metadata gives an area's name, label and type but not its code or - * nation. The count needs every published area to have a code in the - * nation. + * 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. The count + * follows the hex map's rule (withheldUKAreaCodes): each published area's + * ONS code against the layout, all of it for a UK-wide report and the + * nation's for a nation's report, so the note under the map and the + * areas it draws as withheld always agree. A published area with no code, + * or a code outside the region's layout, gives no count. */ export function countWithheldUKAreas({ level, region, - publishedNames, - publishedCodes = [], - regions, + publishedCodes, }: { level: UKAreaLevel; region?: string | null; - publishedNames: string[]; - /** The published areas' ONS codes, one per area, for a nation report. */ - publishedCodes?: (string | undefined)[]; - regions: Region[]; + /** The published areas' ONS codes, one per area. */ + publishedCodes: (string | undefined)[]; }): UKWithheldAreas { - if (region && region.toLowerCase() !== 'uk') { - const nation = ukNationFromRegion(region); - if ( - !nation || - publishedCodes.length !== publishedNames.length || - publishedCodes.some((code) => 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; } /** @@ -198,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 ?? {}; @@ -207,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/flagship/report/UKLocalAreaMaps.tsx b/app/src/components/flagship/report/UKLocalAreaMaps.tsx index 3499ee442..f5e79fa39 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,7 @@ import { ukNationFromRegion, } from '@/components/visualization/choropleth/ukAreaBoundaries'; import { UKAreaChoroplethMap } from '@/components/visualization/choropleth/UKAreaChoroplethMap'; -import { ukHexPosition } 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'; @@ -251,27 +252,28 @@ 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] + // A level the breakdown supplied is disclosure-controlled: the areas of + // the region it leaves out are the ones withheld. A saved level has none. + const levelWithheld = withheld?.[level] !== undefined; + // 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]); @@ -396,8 +398,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 ? 'Not published' : 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} @@ -405,12 +408,14 @@ export default function UKLocalAreaMaps({ /> ) : ( ; @@ -283,6 +295,28 @@ 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 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 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_PIXELS = 4; + /** * SVG polygon points for a hexagon centred at (cx, cy): flat-topped, or * pointy-topped for a tiled grid. @@ -320,6 +354,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 +393,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 +404,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 +431,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,22 +459,31 @@ export function HexagonalMap({ ? (positionedData.find((point) => point.id === selectedId) ?? null) : null; - // Calculate data bounds + // 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) + : 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, 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) { @@ -445,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; @@ -459,6 +527,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_PIXELS / screenPixelsPerUnit, + hexPixelSize / MISSING_AREA_STRIPES_PER_RADIUS + ); const formatTick = fullConfig.formatColorBarTick ?? defaultFormatTick; const ticks = colorBarTicks(colorRange.min, colorRange.max); @@ -493,9 +565,51 @@ 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(); + 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); @@ -516,16 +630,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/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/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 2b8eecd70..cc543f8cf 100644 --- a/app/src/components/visualization/choropleth/ukHexLayout.ts +++ b/app/src/components/visualization/choropleth/ukHexLayout.ts @@ -1,12 +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 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> = { @@ -14,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. @@ -28,5 +43,144 @@ 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. */ +export interface UKHexMapPlacement { + points: HexMapDataPoint[]; + /** Whether the layout placed every point, so the map draws the tiled grid its positions need. */ + tiled: boolean; +} + +/** + * 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 { + 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, + }; +} + +/** 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. + * + * 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. + */ +export function withheldUKHexAreas( + level: UKBoundaryLevel, + points: Pick[], + region?: string | null +): HexMapMissingArea[] { + const withheld = withheldUKAreaCodes( + level, + points.map((point) => point.code), + region + ); + // 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. */ +export interface UKHexMap extends UKHexMapPlacement { + /** + * The region's areas the result leaves out, which the map draws as not + * reported: only for a disclosure-controlled level, on the tiled grid. + */ + withheldAreas: HexMapMissingArea[]; +} + +/** + * A level's hex map, as v2's charts and v3's local-area map draw it: the + * result's areas placed from the layout, all or none (placeOnUKHexLayout), + * and, for a level from a disclosure-controlled breakdown, the region's + * areas the result leaves out (withheldUKHexAreas). Withheld areas are + * listed only when the map is tiled: a result that keeps its own positions + * cannot be matched to the layout. A saved level has none. + */ +export function placeUKHexMap( + level: UKBoundaryLevel, + points: HexMapDataPoint[], + { disclosureControlled, region }: { disclosureControlled: boolean; region?: string | null } +): UKHexMap { + const placement = placeOnUKHexLayout(level, points); + return { + ...placement, + withheldAreas: + disclosureControlled && placement.tiled ? withheldUKHexAreas(level, points, region) : [], + }; } diff --git a/app/src/data/ukHexLayouts/README.md b/app/src/data/ukHexLayouts/README.md index c3fda58b5..123555cc4 100644 --- a/app/src/data/ukHexLayouts/README.md +++ b/app/src/data/ukHexLayouts/README.md @@ -1,27 +1,34 @@ # 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 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 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 | |---|---|---|---| | `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/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/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx b/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx index 04184b94f..55952ebec 100644 --- a/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx +++ b/app/src/pages/report-output/constituency/AbsoluteChangeByConstituency.tsx @@ -1,6 +1,9 @@ import { useMemo, useRef, useState } from 'react'; import { transformConstituencyAbsoluteChange } from '@/adapters/constituency/constituencyDataAdapter'; -import type { UKWithheldAreas } from '@/adapters/ukLocalAreaBreakdownAdapter'; +import { + UK_WITHHELD_AREA_TEXT, + type UKWithheldAreas, +} from '@/adapters/ukLocalAreaBreakdownAdapter'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { MapDownloadMenu } from '@/components/MapDownloadMenu'; import UKAreaDisclosureNotes from '@/components/report/UKAreaDisclosureNotes'; @@ -11,6 +14,7 @@ import { ukNationFromRegion, type MapVisualizationType, } from '@/components/visualization/choropleth'; +import { placeUKHexMap } 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 +47,13 @@ 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, 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 = ; if (!mapData.length) { @@ -85,12 +96,15 @@ 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 : 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, 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 = ; if (!mapData.length) { @@ -85,12 +96,15 @@ export function RelativeChangeByConstituency({ 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 : UK_WITHHELD_AREA_TEXT} /> ) : ( (areas ? transformLocalAuthorityAbsoluteChange(areas) : []), [areas] ); + // 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 = ; if (!mapData.length) { @@ -88,12 +99,15 @@ export function AbsoluteChangeByLocalAuthority({ 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 : UK_WITHHELD_AREA_TEXT} /> ) : ( (areas ? transformLocalAuthorityRelativeChange(areas) : []), [areas] ); + // 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 = ; if (!mapData.length) { @@ -88,12 +99,15 @@ export function RelativeChangeByLocalAuthority({ 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 : UK_WITHHELD_AREA_TEXT} /> ) : ( 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/components/flagship/report/ukLocalAreaMapsMocks.ts b/app/src/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks.ts index 4e2462390..f29cd7a88 100644 --- a/app/src/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks.ts +++ b/app/src/tests/fixtures/components/flagship/report/ukLocalAreaMapsMocks.ts @@ -89,3 +89,21 @@ export const UK_LOCAL_AREAS_OUTPUT_WITHOUT_API_POSITIONS = { ), }, } as unknown as SocietyWideReportOutput; + +/** + * The coded output with Gower's code taken away, as from a source with no + * code for it: the layout cannot place every area, so the hex map keeps + * the result's own positions. + */ +export const UK_LOCAL_AREAS_OUTPUT_GOWER_UNCODED = (() => { + 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/ukAreaChoroplethMapMocks.ts b/app/src/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks.ts index 557ba8d07..69e143f48 100644 --- a/app/src/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks.ts +++ b/app/src/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks.ts @@ -132,7 +132,7 @@ export const MOCK_UK_CONSTITUENCY_POINTS_WITHOUT_CODES: UKAreaChoroplethDataPoin ]; /** The text a boundary with no result reads when disclosure control withheld it. */ -export const NOT_PUBLISHED_TEXT = 'Not published'; +export const NOT_REPORTED_TEXT = 'Not reported'; /** The map type toggle's options, as MapTypeToggle labels them. */ export const GEOGRAPHIC_MAP_TAB = 'Geographic'; diff --git a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts index f71f0fe56..dcaa3970a 100644 --- a/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts +++ b/app/src/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks.ts @@ -1,3 +1,5 @@ +import type { HexMapDataPoint } from '@/types/visualization/HexMapDataPoint'; + /** A constituency and its `[q, r]` position in data/ukHexLayouts/constituencies_2024.json. */ export const BARNSLEY_NORTH_CODE = 'E14001074'; export const BARNSLEY_NORTH_HEX = [57, -23]; @@ -5,3 +7,92 @@ export const BARNSLEY_NORTH_HEX = [57, -23]; /** A local authority and its position in data/ukHexLayouts/local_authorities_2023.json. */ export const HARTLEPOOL_CODE = 'E06000001'; export const HARTLEPOOL_HEX = [8, 18]; + +/** Barnsley North's hex-map point, at the position its result gives it. */ +export const BARNSLEY_NORTH_POINT: HexMapDataPoint = { + id: 'Barnsley North', + label: 'Barnsley North', + value: 120, + x: BARNSLEY_NORTH_HEX[0], + y: BARNSLEY_NORTH_HEX[1], + code: BARNSLEY_NORTH_CODE, +}; + +/** Hartlepool's hex-map point, at the position its result gives it. */ +export const HARTLEPOOL_POINT: HexMapDataPoint = { + id: 'Hartlepool', + label: 'Hartlepool', + value: -60, + x: 8, + y: 19, + code: HARTLEPOOL_CODE, +}; + +/** A point from an older saved result, which gives a position and no code. */ +export const UNCODED_POINT: HexMapDataPoint = { + id: 'Westminster North', + label: 'Westminster North', + value: -40, + 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, +}; + +/** 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' }, +]; + +/** 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/fixtures/components/visualization/hexMapMocks.ts b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts index ba34b431e..fa106fbda 100644 --- a/app/src/tests/fixtures/components/visualization/hexMapMocks.ts +++ b/app/src/tests/fixtures/components/visualization/hexMapMocks.ts @@ -1,4 +1,6 @@ -import type { HexMapDataPoint } from '@/types/visualization/HexMapDataPoint'; +import { vi } from 'vitest'; +import { colors } from '@/designTokens'; +import type { HexMapDataPoint, HexMapMissingArea } from '@/types/visualization/HexMapDataPoint'; export const WESTMINSTER_NORTH: HexMapDataPoint = { id: 'westminster-north', @@ -80,3 +82,84 @@ 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'; + +/** 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]; + +/** 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; +} + +/** 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/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/fixtures/pages/local-authority/localAuthorityComponentMocks.ts b/app/src/tests/fixtures/pages/local-authority/localAuthorityComponentMocks.ts index 4f447c277..c397e43d6 100644 --- a/app/src/tests/fixtures/pages/local-authority/localAuthorityComponentMocks.ts +++ b/app/src/tests/fixtures/pages/local-authority/localAuthorityComponentMocks.ts @@ -1,3 +1,4 @@ +import { HARTLEPOOL_CODE } from '@/tests/fixtures/components/visualization/choropleth/ukAreaChoroplethMapMocks'; import type { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK'; /** @@ -205,3 +206,127 @@ export const MOCK_UK_REPORT_OUTPUT_NO_LA_FIELD = (() => { 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, + }, + }, + }, +}; + +/** + * 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/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx b/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx new file mode 100644 index 000000000..5b26060e5 --- /dev/null +++ b/app/src/tests/fixtures/pages/report-output/ukHexMapPlacementMocks.tsx @@ -0,0 +1,97 @@ +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'; +import type { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK'; + +export interface HexagonCentre { + x: number; + y: number; +} + +/** A hexagon polygon's vertices, in drawing order. */ +export function hexagonVertices(polygon: Element): [number, number][] { + return (polygon.getAttribute('points') ?? '') + .trim() + .split(' ') + .map((pair) => pair.split(',').map(Number) as [number, number]); +} + +/** A hexagon polygon's centre: the mean of its vertices. */ +export 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/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/UKLocalAreaMaps.test.tsx b/app/src/tests/unit/components/flagship/report/UKLocalAreaMaps.test.tsx index 05a6a0798..cb103d9e2 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, @@ -25,21 +26,51 @@ import { CARDIFF_EAST_CODE, CLWYD_EAST_CODE, GOWER_CODE, + HARTLEPOOL_CODE, MAP_LEGEND_NAME, mockUKBoundaryFetch, MONMOUTHSHIRE_CODE, - NOT_PUBLISHED_TEXT, + 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, 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}"]`)!; } +/** 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}`); @@ -302,6 +333,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(); @@ -372,7 +427,7 @@ describe('UKLocalAreaMaps geographic map', () => { 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 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 () => { @@ -567,7 +660,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 () => { @@ -621,7 +714,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 () => { @@ -690,3 +783,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/components/flagship/report/UKLocalAreaSection.test.tsx b/app/src/tests/unit/components/flagship/report/UKLocalAreaSection.test.tsx index 77a4996a0..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) { @@ -151,7 +147,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,35 +158,37 @@ describe('UKLocalAreaSection', () => { // Then expect( - await screen.findByText(/^2 of 3 constituencies are not published\./) + await screen.findByText(/^649 of 650 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(/^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 published\./)).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 published\./) + 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 published\./)).not.toBeInTheDocument(); }); test('given saved area data then no disclosure note shows', async () => { @@ -208,7 +224,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 +251,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/HexagonalMap.test.tsx b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx index 5413e308e..681760eec 100644 --- a/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx +++ b/app/src/tests/unit/components/visualization/HexagonalMap.test.tsx @@ -2,12 +2,22 @@ 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, + MISSING_NORTH_EAST, + MISSING_UK_SHAPED_AREA, MOCK_HEX_MAP_DATA, MOCK_MIXED_VALUES, MOCK_NEGATIVE_VALUES, 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. */ @@ -490,6 +500,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 @@ -526,3 +564,227 @@ 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 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 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 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(); + + // Then + expect(container.querySelector('pattern')).not.toBeInTheDocument(); + expect(container.querySelector('[data-missing-area-id]')).not.toBeInTheDocument(); + }); +}); 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/components/visualization/choropleth/ukHexLayout.test.ts b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts index fe7d2f903..46f8c8572 100644 --- a/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts +++ b/app/src/tests/unit/components/visualization/choropleth/ukHexLayout.test.ts @@ -1,11 +1,35 @@ import { describe, expect, test } from 'vitest'; -import { UK_HEX_LAYOUTS, ukHexPosition } from '@/components/visualization/choropleth/ukHexLayout'; import { + placeOnUKHexLayout, + placeUKHexMap, + UK_HEX_LAYOUTS, + ukHexPosition, + withheldUKHexAreas, +} from '@/components/visualization/choropleth/ukHexLayout'; +import { + ALLERDALE_CODE, + ALLERDALE_POINT, 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', () => { @@ -65,3 +89,208 @@ describe('ukHexPosition', () => { expect(Math.max(...scottishRows)).toBeGreaterThan(Math.max(...englishRows)); }); }); + +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]); + + // 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 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).toEqual({ points: [BARNSLEY_NORTH_POINT, HARTLEPOOL_POINT], tiled: false }); + }); + + test('given a local authority on a pre-2023 code then every point keeps its own position, untiled', () => { + // When + const placement = placeOnUKHexLayout('local_authority', [DARLINGTON_POINT, ALLERDALE_POINT]); + + // Then + expect(placement).toEqual({ points: [DARLINGTON_POINT, ALLERDALE_POINT], tiled: false }); + }); + + test('given every code in the layout then no point keeps its own position', () => { + // When + const placement = placeOnUKHexLayout('local_authority', [DARLINGTON_POINT, HARTLEPOOL_POINT]); + + // Then + expect(placement.points.map(({ x, y }) => [x, y])).toEqual([ + [7, 19], + [HARTLEPOOL_HEX[0], HARTLEPOOL_HEX[1] + 1], + ]); + 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([]); + }); +}); + +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: [], + }); + }); +}); diff --git a/app/src/tests/unit/data/ukBoundaryFiles.test.ts b/app/src/tests/unit/data/ukBoundaryFiles.test.ts index c5a62621f..2f8a666b5 100644 --- a/app/src/tests/unit/data/ukBoundaryFiles.test.ts +++ b/app/src/tests/unit/data/ukBoundaryFiles.test.ts @@ -1,14 +1,15 @@ import * as fs from 'fs'; import * as path from 'path'; import { describe, expect, test } from 'vitest'; +import { ukNationFromCode } from '@/components/visualization/choropleth/ukAreaBoundaries'; import { - UK_AREA_COUNTS_BY_NATION, - ukNationFromCode, -} from '@/components/visualization/choropleth/ukAreaBoundaries'; -import { UK_HEX_LAYOUTS } from '@/components/visualization/choropleth/ukHexLayout'; + UK_HEX_AREA_NAMES, + UK_HEX_LAYOUTS, +} from '@/components/visualization/choropleth/ukHexLayout'; import { BOUNDARY_FILE_DIRECTORY, CONSTITUENCY_BOUNDARY_FILE, + EXPECTED_AREA_COUNTS_BY_NATION, EXPECTED_CONSTITUENCY_COUNT, EXPECTED_LOCAL_AUTHORITY_COUNT, LOCAL_AUTHORITY_BOUNDARY_FILE, @@ -64,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`, () => { @@ -94,6 +95,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); @@ -126,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; 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 47c1e6f72..66160426f 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 { @@ -27,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'; @@ -96,10 +91,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(); }); @@ -112,7 +111,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 +127,7 @@ describe('ConstituencySubPage', () => { // Then expect( - await screen.findByText(/^2 of 3 constituencies are not published\./) + 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 fa4de3bf5..6f0ad3586 100644 --- a/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/LocalAuthoritySubPage.test.tsx @@ -18,10 +18,13 @@ 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_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, @@ -37,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) { @@ -156,17 +153,21 @@ 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}:`)) ).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,11 +183,11 @@ describe('LocalAuthoritySubPage', () => { // Then expect( - await screen.findByText(/^2 of 3 local authorities are not published\./) + 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( @@ -204,9 +205,31 @@ describe('LocalAuthoritySubPage', () => { // Then expect( - await screen.findByText( - /^Local authorities whose estimates fail the survey data’s disclosure rules are not published\./ - ) + 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(), 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..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 @@ -1,8 +1,12 @@ 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, + 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'; @@ -13,13 +17,34 @@ import { GOWER_CODE, MAP_LEGEND_NAME, mockUKBoundaryFetch, - NOT_PUBLISHED_TEXT, + 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 { + 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, } from '@/tests/fixtures/pages/constituency/constituencyComponentMocks'; -import { withNullAreaImpacts } from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; +import { + hexagonArrangement, + hexagonVertices, + isPointyTopped, + v2Hexagons, + v3HexagonArrangement, +} from '@/tests/fixtures/pages/report-output/ukHexMapPlacementMocks'; +import { + BREAKDOWN_CONSTITUENCY_IMPACT, + V2_UK_REGION, + withNullAreaImpacts, +} from '@/tests/fixtures/pages/report-output/ukLocalAreaBreakdownPageMocks'; beforeEach(() => { vi.stubGlobal('fetch', mockUKBoundaryFetch()); @@ -92,7 +117,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(); @@ -113,7 +138,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(); @@ -154,7 +179,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( @@ -169,6 +194,166 @@ 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}`); + }); +}); + +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 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( + 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); + }); +}); + +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); + }); +}); + +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(); + } }); }); 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..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 @@ -1,17 +1,36 @@ 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_GOWER_UNCODED, + 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 { + CARDIFF_EAST_NAME, + WELSH_CONSTITUENCY_COUNT, +} from '@/tests/fixtures/components/visualization/choropleth/ukHexLayoutMocks'; 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 +130,153 @@ 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); + }); +}); + +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}`); + }); +}); + +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 567a8963d..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 @@ -1,21 +1,40 @@ -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'; 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 { CARDIFF_CODE, HARTLEPOOL_CODE, MAP_LEGEND_NAME, 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_PRE_2023_LA_UNCODED, + 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 +143,181 @@ 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 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); + + // 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); + }); +}); + +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}`); + }); +}); + +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 0e0bcd8bf..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 @@ -1,17 +1,39 @@ 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 { CARDIFF_CODE, + HARTLEPOOL_CODE, MAP_LEGEND_NAME, 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 { + 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 +133,167 @@ 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); + }); +}); + +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}`); + }); +}); + +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); + }); +}); 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; /** diff --git a/app/src/types/visualization/HexMapDataPoint.ts b/app/src/types/visualization/HexMapDataPoint.ts index 983d7dcac..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. */ @@ -41,3 +41,22 @@ 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 with a grey edge, 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: a column, which can be negative */ + x: number; + + /** Y-coordinate on the hexagonal grid: a row, which can be negative */ + 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,