Skip to content

Draw v2's constituency and local-authority hex maps from the app's layout files - #1433

Closed
anth-volk wants to merge 6 commits into
mainfrom
fix/v2-uk-hex-layouts
Closed

anth-volk wants to merge 6 commits into
mainfrom
fix/v2-uk-hex-layouts

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1432

What changed

v2's constituency and local-authority hex maps now place each area from the app's own layout files, as v3's do. Before, they used the x and y in the result.

  • New function: placeOnUKHexLayout(level, points) in components/visualization/choropleth/ukHexLayout.ts. It replaces the rule v3's UKLocalAreaMaps had inline. When the layout has every point's ONS code, each point takes ukHexPosition's position and the map tiles (config.tiled). Otherwise no point moves; see below.
  • Callers: v3's UKLocalAreaMaps and the four v2 charts all call it, so v2 and v3 put every area in the same hex. The four charts are AbsoluteChangeByConstituency, RelativeChangeByConstituency, AbsoluteChangeByLocalAuthority and RelativeChangeByLocalAuthority. They are the only places v2 draws these hex maps: ConstituencySubPage and LocalAuthoritySubPage render them, both on the report's tabs and in the UK sections of MigrationSubPage.
  • v3: v3 now calls the function in place of its inline rule. It draws the same map as before, except for a result that only some of whose areas the layout can place. That case is covered below.

An area missing from the layout

Placement is all or none. If the layout has no position for even one of a result's areas, every area in that result keeps the position its result gives it, and the map draws the untiled square grid those positions were made for. That covers two kinds of area:

  • one with no code, as in older saved results in the keyed by_constituency shape;
  • one whose code the layout lacks, such as a district abolished in April 2023, for example Allerdale (E07000026), Craven (E07000163) or Mendip (E07000187). The layouts follow the April 2023 boundaries.

v3's inline rule, from #1430, placed whichever areas it could and left the rest at their result positions, all on the untiled grid. Those are two different grids, a row apart for constituencies and wholly different for local authorities, so such a map overlapped or scattered its hexagons. Results from the list shape and from local-area breakdowns carry current codes, and the layouts hold all 650 constituencies and 361 local authorities, so the fallback applies only to older saved results.

The follow-up PR removes this fallback and the rest of the app's handling of x and y.

What it fixes

  • Constituencies: the result positions equal the layout's [q, r]. v2 shifted the wrong row parity and spaced rows a full column apart, so every other row sat half a hex out of place.
  • Local authorities: v2 drew Cumberland, Westmorland and Furness, North Yorkshire, Somerset, and Bournemouth, Christchurch and Poole in one hex, because the Public API gives each of them (0, 0).

Hexagons kept inside the frame

Tiling v2's maps exposed a sizing fault in HexagonalMap, which v3's map of a nation already had. On the tiled grid, a hexagon's radius is 0.55 of the scale, but the map fitted only the hexagons' centres inside a margin of twice hexSize. A map of few areas, such as one nation's constituencies, therefore drew its top and bottom hexagons past the frame. They were clipped on screen and in the download.

The tiled map's bounds now include each hexagon's edges, so the scale leaves room for them. HexMapConfig's hexSize comment now says what it sets on each grid.

Tests

The work was done red-green: each commit's tests were written first and seen failing before the code.

  • placeOnUKHexLayout (ukHexLayout.test.ts):

    • a coded point takes its layout position, on the tiled grid;
    • a point with no code, a code from the other level, or a pre-2023 local-authority code means every point keeps its own position, untiled.
  • Each of the four v2 charts:

    • with coded areas, the hex map's arrangement equals v3's UKLocalAreaMaps for the same result. The test renders both maps, then compares hexagon centres, normalised for the maps' different sizes;
    • with coded areas, the hexagons are the tiled grid's pointy-topped ones;
    • without codes, the arrangement still equals v3's, on the untiled grid. This guards the fallback and passed before the change too.
  • A pre-2023 local-authority code: AbsoluteChangeByLocalAuthority and v3's UKLocalAreaMaps draw a saved result with pre-2023 codes exactly as they draw the same result without codes: the same arrangement, on the untiled grid.

  • The local-authority charts also check that the five authorities the Public API stacks at (0, 0) now get five separate hexes. The fixture uses the Public API's real positions.

  • HexagonalMap checks that a tiled map of three areas and a UK-shaped tiled map keep every hexagon inside the frame. AbsoluteChangeByConstituency checks the same for three Welsh constituencies. Before the fix, the top hexagon reached y = −66.5 and y = −15.6 respectively.

Before the change, v2's arrangement of three Welsh constituencies had the second at (0.164, 0.986) relative to the first, where v3's has it at (0.189, 0.982). Its five local authorities had only three distinct centres.

Review

  • Round 1: no bugs. It found that the layouts README said only v3 uses the files. That is fixed, and the comparison fixture now uses the existing UKAreaLevel type.
  • Round 2: one bug, the clipped hexagons above, now fixed with tests written first and seen failing. It also noted that hexSize's comment and the README went too far. Both are corrected: the README now says an area without a layout position still uses its result's x and y.
  • Mixed placement: a reviewer found that a partly placed map mixed the two grids. It is now all or none, fixed with tests written first and seen failing: the placement unit tests, and the v2 and v3 tests with pre-2023 codes. Before the fix, Darlington moved to its layout row while Allerdale, Craven and Mendip kept their result positions.

To check by hand

On a UK report with constituency results (for example a saved one, or one whose local-area breakdown has run):

  1. Open the v2 constituency tab and choose Hex grid. The hexagons should be pointy-topped and touching, in the same arrangement as the v3 report's Local areas hex map.
  2. On a Wales report, the hex map's top and bottom hexagons should sit wholly inside the frame.
  3. Do the same on the v2 local-authority tab. Cumberland, Westmorland and Furness, and North Yorkshire should be three separate hexes in the north of England, where v2 drew them as one.

🤖 Generated with Claude Code

anth-volk and others added 2 commits October 27, 2026 15:53
v2's absolute and relative constituency hex maps drew each constituency
at the x and y its result gives, on the untiled square grid. Those
positions are the Open Innovations layout's [q, r], but the untiled grid
shifts the even rows where the layout's odd-r rows need the odd ones
shifted, so v2's arrangement differed from v3's in every other row.

Both now place their areas with placeOnUKHexLayout, which holds the
rule v3's local-area map had inline: each area whose ONS code the layout
has takes the layout's position, on the tiled grid. An area the layout
cannot place (no code, as in an older saved result, or a code the layout
lacks) keeps its result's position, and the map then draws the untiled
grid those positions were made for. v3's map now calls the same
function, with no change in what it draws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v2's absolute and relative local-authority hex maps drew each authority
at the x and y its result gives, on the untiled square grid. The Public
API's positions differ from the layout for 56 of the 361 authorities,
and put Cumberland, Westmorland and Furness, North Yorkshire, Somerset
and Bournemouth, Christchurch and Poole, created in 2019 and 2023, all
at (0, 0), so v2 drew them on top of one another.

Both maps now place their areas with placeOnUKHexLayout, as v3's
local-area map and v2's constituency maps do: each authority whose ONS
code the layout has takes the layout's position, on the tiled grid. An
area the layout cannot place keeps its result's position, on the
untiled grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-calculator-next Ready Ready Preview Oct 27, 2026 2:52pm UTC
policyengine-website Ready Ready Preview Oct 27, 2026 2:52pm UTC

Request Review

The README said only v3's "Local areas" tab draws from the layout
files. v2's constituency and local-authority tabs now do as well,
through placeOnUKHexLayout. The test fixture that compares v2's and
v3's hex maps also takes UKAreaLevel from its adapter instead of
repeating the type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 2 commits October 27, 2026 16:10
On the tiled grid a hexagon's radius is 0.55 of the scale, but the map
fitted only the hexagons' centres inside a margin of twice hexSize. A
map of few areas has a large scale, so its top and bottom hexagons ran
past the frame and were clipped, in the map and in its download. v2's
hex map of three Welsh constituencies, now tiled, reached y = -15.6 in
its 380-unit frame. v3's map of a nation had the same problem.

The tiled map's bounds now take in each hexagon's edges, its radius up
and down and √3/2 of it sideways, so the scale leaves room for them.
HexMapConfig's hexSize comment now says what it sets on each grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README said the hex maps no longer depend on the x and y the Public
API sends. An area without a code in the layout still falls back to
them, so it now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@PavelMakarchuk PavelMakarchuk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @anth-volk. I checked this against current main (06f6441): it merges cleanly, CI is green, and in a local checkout 47 test files (518 tests) and tsc --noEmit pass. It fixes two real v2 defects: constituency rows half a hex out of place, and the five local authorities the API puts at (0,0), which drew as one hex.

1. A partly placed map mixes two coordinate systems (ukHexLayout.ts, placeOnUKHexLayout)

When some codes are missing from the layout, the placed points take layout positions and the rest keep API positions, and all of them are drawn on the untiled grid. The two grids differ (by a row for constituencies, entirely for local authorities), so the hexes overlap or scatter. This came from my code in #1430, which v3 already has, and the unit test asserts it. It is reachable: the layout has no pre-2023 local-authority codes (E07000026, E07000187, E07000163 are absent), so a saved result with old codes would draw this map. Before this PR, v2 drew such a result from API positions only, which was at least consistent.

Suggested fix: make it all-or-nothing. If any point is missing from the layout, return the points unchanged with tiled: false, and change the test to expect that.

2. Timing

This changes the four v2 report pages under pages/report-output/{constituency,local-authority}/. It is a bug fix, so that is fine, but UK users are on v2 and the Budget is on 28 October. I would merge it after the Budget unless the v2 map fix is needed live tomorrow.

Nits

  • A tiled map still adds padding = hexSize * 2 on top of the new hexagon reach, so the margin is doubled. Harmless.
  • placeOnUKHexLayout([]) returns tiled: true. Every caller returns early on empty input, so it has no effect.
  • The description says the API positions "equal the layout's [q, r]", but ukHexPosition adds 1 to the row.

Merge after the fix in point 1.

When the layout had no position for some of a result's areas,
placeOnUKHexLayout moved the others to their layout positions and left
those at the x and y their results gave, all on the untiled grid. The
two sets of positions are different grids (a row apart for
constituencies, entirely different for local authorities), so the
map's hexagons overlapped or scattered. A saved result from before
April 2023 reaches this: the layout follows the April 2023 boundaries
and has no code for a district abolished then, such as Allerdale
(E07000026), Craven (E07000163) or Mendip (E07000187).

Placement is now all or none. If the layout lacks any point's code,
every point keeps its result's position, on the untiled grid; only when
it has every code do the points take its positions, tiled. v2's and v3's
hex maps both use it. The README and v3's comment say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 27, 2026
#1433 now places a hex map's areas from the layout all or none: if the
layout lacks any area's code, every area keeps its result's position on
the untiled grid. This branch replaces that fallback, so the conflicts
keep its behaviour: placeOnUKHexLayout leaves out an area the layout
cannot place, the map always tiles, and the note counts the areas left
out.

From #1433 it keeps the README's and the function comment's example of
a district abolished in April 2023, the check that the layout has no
pre-2023 local authority code, and the pre-2023 fixtures, without x and
y as this branch's typed fixtures are. The pre-2023 tests now expect
this branch's behaviour: Allerdale is left out while Darlington takes
its layout position, and on v2's and v3's maps the three pre-2023
districts are left off and counted in the note ("3 of 5 local
authorities are not on the hex map ..."). #1433's comparison with the
same result without codes, and its uncoded fixture, are dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk

Copy link
Copy Markdown
Collaborator Author

Superseded by #1448, which carries all of this PR's commits, cherry-picked with -x and unchanged, including the all-or-nothing placement fix for @PavelMakarchuk's review (f4e720cc), together with #1447's change that shows areas withheld by disclosure control as "Not reported" on the hex maps. Anthony decided to ship this before the Budget so the v2 maps show withheld areas, rather than waiting until after it as suggested in the review. #1436 will be rebuilt on top of #1448.

@anth-volk anth-volk closed this Oct 27, 2026
anth-volk added a commit that referenced this pull request Oct 27, 2026
#1448 (1A · 2) holds #1433's six commits, cherry-picked unchanged, and
#1447's work adapted to main: the hex maps draw the areas a
disclosure-controlled breakdown withheld, hatched, as "Not reported",
but only on the tiled grid, because areas still carry x and y there.
This branch now builds on it. The conflicts keep this branch's
behaviour and #1448's withheld areas:

- The app reads no x or y: placeOnUKHexLayout takes the adapters'
  UKAreaChoroplethDataPoint, leaves out an area the layout cannot
  place, and returns only the placed points. #1448's Welsh fixture
  points lose their positions, and its fixture with every API position
  at the origin is dropped.
- HexagonalMap draws only the tiled grid. #1448's missingAreas are
  positioned as the valued areas are, by a positionOnGrid with no
  coordinate scale or row-spacing argument, and count in the bounds.
  The duplicate "tiled frame" tests and the missing-area tests'
  tiled config are dropped.
- No note says which areas are left off.
- The withheld areas are kept. Under this branch every map tiles, so
  the callers' "only when tiled" checks are gone: v3's UKLocalAreaMaps
  and the four v2 charts ask withheldUKHexAreas for a breakdown level's
  withheld areas whenever the level is disclosure-controlled.

withheldUKHexAreas still lists none when any of the result's areas has
no code or a code outside the region's layout. That check is about
matching the result to the layout, not about the grid: a layout area
the result lacks may be covered by one of its unmatched areas, such as
the districts a 2023 unitary authority replaced, so calling it withheld
could be wrong. A map that leaves out unplaceable areas therefore draws
no withheld areas, and the function's comment says why. #1448's v3 test
for a breakdown area without a code now expects that: Gower left off,
the other constituencies tiled, and no withheld areas.

The README keeps both sides' text, and the tests take #1448's "not
reported" wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk removed this pull request from stack #1437 October 27, 2026 17:00

This branch was successfully deployed

2 active deployments
Preview – policyengine-website — f4e720cc Deployed Oct 27, 2026 by vercel[bot]
Preview – policyengine-calculator-next — f4e720cc Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v2's UK hex maps place areas differently from v3's

2 participants