Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d2e72f8
Place v2's constituency hex maps from the app's layout, as v3 does
anth-volk Oct 27, 2026
d130de1
Place v2's local-authority hex maps from the app's layout, as v3 does
anth-volk Oct 27, 2026
3a48e68
Say in the layouts README that v2's hex maps use them too
anth-volk Oct 27, 2026
c6f816f
Keep tiled hexagons inside the hex map's frame
anth-volk Oct 27, 2026
71dc49e
Say in the layouts README which areas still use x and y
anth-volk Oct 27, 2026
07d096c
Place a UK hex map's areas from the layout all or none
anth-volk Oct 27, 2026
41cbbeb
Say "not reported" for UK areas disclosure control withholds
anth-volk Oct 27, 2026
b1c66a3
Draw areas without a value on the hex map, hatched
anth-volk Oct 27, 2026
4dd7084
List the areas of a report's region that a UK result leaves out
anth-volk Oct 27, 2026
b3f8cd4
Draw UK areas disclosure control withheld on the hex maps
anth-volk Oct 27, 2026
f8a4d1f
Read "Not reported" only on a breakdown level's v3 geographic map
anth-volk Oct 27, 2026
7a10d52
Keep a withheld hexagon's hatching distinct on the dense UK map
anth-volk Oct 27, 2026
9ec3bf8
Say in the hex map's docs that a withheld hexagon has a grey edge
anth-volk Oct 27, 2026
08f9cb0
Rewrap HexagonalMap's missingAreas documentation
anth-volk Oct 27, 2026
f7575a8
Place a UK hex map and its withheld areas with one shared function
anth-volk Oct 27, 2026
e83c161
Count the withheld-area note by code against the layout, as the map does
anth-volk Oct 27, 2026
e4e754d
Space a withheld hexagon's stripes at least 4 screen pixels on a phone
anth-volk Oct 27, 2026
3fbc1ec
Keep a withheld hexagon's stripes the same on screen at any zoom
anth-volk Oct 27, 2026
4a67468
Check the hex map's area names against .py's area lists
anth-volk Oct 27, 2026
4203dfd
Drop withheldUKHexAreas' position guard, which cannot fail
anth-volk Oct 27, 2026
867af2c
Say that hex-map coordinates can be negative, not 0-based
anth-volk Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 20 additions & 58 deletions app/src/adapters/ukLocalAreaBreakdownAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,6 +121,12 @@ export const UK_AREA_NOUNS: Record<UKAreaLevel, { one: string; many: string }> =
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.
Expand All @@ -136,60 +137,26 @@ export type UKWithheldAreas = { withheld: number; total: number } | null;
export type UKBreakdownWithheldAreas = Partial<Record<UKAreaLevel, UKWithheldAreas>>;

/**
* 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;
}

/**
Expand All @@ -198,25 +165,20 @@ 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 ?? {};
return {
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,
}),
};
}
Expand Down
37 changes: 21 additions & 16 deletions app/src/components/flagship/report/UKLocalAreaMaps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -396,21 +398,24 @@ 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}
ariaLabel={`${capitalize(noun.one)} map, coloured by the change. The lists of ${noun.many} give the values.`}
/>
) : (
<HexagonalMap
data={mapData}
data={hexMap.points}
missingAreas={hexMap.withheldAreas}
missingAreaText={UK_WITHHELD_AREA_TEXT}
config={{
...mapConfig,
// The legend reads in the rankings' units: pounds or percentages.
formatColorBarTick: formatValue,
tiled: hexLayoutOwned,
tiled: hexMap.tiled,
}}
exportRef={mapRef}
selectedId={selectedArea ? selectedName : null}
Expand Down
6 changes: 3 additions & 3 deletions app/src/components/report/UKAreaDisclosureNotes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ function capitalize(text: string): string {
function withheldNote(level: UKAreaLevel, withheld: UKWithheldAreas): string | null {
const noun = UK_AREA_NOUNS[level];
if (withheld === null) {
return `${capitalize(noun.many)} whose estimates fail the survey data’s disclosure rules are not published. The rules ${WITHHOLDING_RULE}`;
return `${capitalize(noun.many)} whose estimates fail the survey data’s disclosure rules are not reported. The rules ${WITHHOLDING_RULE}`;
}
if (withheld.withheld <= 0) {
return null;
}
const verb = withheld.withheld === 1 ? 'is' : 'are';
return `${withheld.withheld} of ${withheld.total} ${noun.many} ${verb} not published. The survey data’s disclosure rules ${WITHHOLDING_RULE}`;
return `${withheld.withheld} of ${withheld.total} ${noun.many} ${verb} not reported. The survey data’s disclosure rules ${WITHHOLDING_RULE}`;
}

function approximateNote(level: UKAreaLevel, count: number): string | null {
Expand All @@ -40,7 +40,7 @@ function approximateNote(level: UKAreaLevel, count: number): string | null {

/**
* Notes under a UK local-area map from a disclosure-controlled breakdown:
* how many of the level's areas were not published and why, and how many
* how many of the level's areas were not reported and why, and how many
* shown areas are approximate. A saved result (no `withheld`, no tiers)
* gets no notes.
*/
Expand Down
Loading
Loading