Hex Map Comes Out Scrambled: Offset Coordinates and Negative Rows

On this page

Problem statement

You draw a HexJSON layout — UK constituencies, local authorities, US states — and the result looks almost right but wrong in a way that is hard to name. Hexagons overlap in pairs. Rows staircase diagonally. Or the map looks tidy but places are a hexagon away from their neighbours. The cause is nearly always the conversion from the file's offset coordinates to positions on the page. HexJSON stores q (column) and r (row) and a layout that says which rows or columns are shifted. Get the parity test or the layout type wrong and every other row moves.

This page reproduces the mistakes on the Open Innovations layout of the 650 UK constituencies used for the 2024 general election. It measures what each one does — overlaps, and real neighbours that are no longer adjacent — and shows the fix.

Quick answer

Read layout from the file, and test oddness in a way that works for negative numbers:

layout = hexjson["layout"]                              # "odd-r", "even-r", "odd-q" or "even-q"
q, r = hexes.q.to_numpy(), hexes.r.to_numpy()
if layout.endswith("-r"):                               # pointy-top hexes, rows shifted
    shift = (r % 2 == 1) if layout == "odd-r" else (r % 2 == 0)       # Python's % is never negative
    x, y = q * np.sqrt(3) + shift * np.sqrt(3) / 2, r * 1.5
else:                                                   # flat-top hexes, columns shifted
    shift = (q % 2 == 1) if layout == "odd-q" else (q % 2 == 0)
    x, y = q * 1.5, r * np.sqrt(3) + shift * np.sqrt(3) / 2
print(layout, f"{len(q)} hexes; overlapping pairs: {len(cKDTree(np.c_[x, y]).query_pairs(np.sqrt(3) - 1e-6))}")
odd-r 650 hexes; overlapping pairs: 0
Three versions of the UK constituency hex map coloured by 2024 winner. The correct odd-r conversion gives a compact, recognisable Britain. Reading the file as even-r gives a similar outline with rows slid against each other. Testing oddness with a C-style remainder leaves every row unshifted, so the hexagons stack in a stair-stepped grid and overlap their neighbours. Three versions of the UK constituency hex map coloured by 2024 winner. The correct odd-r conversion gives a compact, recognisable Britain. Reading the file as even-r gives a similar outline with rows slid against each other. Testing oddness with a C-style remainder leaves every row unshifted, so the hexagons stack in a stair-stepped grid and overlap their neighbours.
The same HexJSON file drawn with the correct conversion and two common mistakes. Data: Open Innovations; House of Commons Library results.

What the layout field means, the mistakes scored, the fix

1. Know what the layout field means

In an odd-r layout, hexagons are pointy-topped, rows are 1.5 radii apart, columns √3 radii apart, and every odd row is pushed half a column to the right. even-r pushes the even rows instead. odd-q and even-q are the flat-topped equivalents, shifting columns. Two hexagons that touch are exactly √3 apart, so any pair closer than that overlaps:

print("layout:", hexjson["layout"], "| rows", r.min(), "to", r.max(), "| columns", q.min(), "to", q.max())
print(f"rows that are negative: {(r < 0).sum()} of {len(r)}")
layout: odd-r | rows -46 to 0 | columns 42 to 72
rows that are negative: 649 of 650

Nearly every row number in this file is negative. That is legal — HexJSON coordinates can start anywhere — and it is where the commonest bug comes from.

2. Reproduce the mistakes and score them

Use the real constituency boundaries to list pairs of constituencies that touch, then check how many of those pairs end up as adjacent hexagons under each conversion:

def positions(kind):
    if kind == "correct":
        off = np.where(r % 2 == 1, np.sqrt(3) / 2, 0)
    elif kind == "read as even-r":
        off = np.where(r % 2 == 0, np.sqrt(3) / 2, 0)
    elif kind == "C-style remainder":
        off = np.where(np.fmod(r, 2) == 1, np.sqrt(3) / 2, 0)        # -1 for odd negative rows
    else:
        off = np.zeros(len(r))
    return np.c_[q * np.sqrt(3) + off, r * 1.5]

for kind in ("correct", "read as even-r", "C-style remainder", "no offset"):
    t = cKDTree(positions(kind))
    overlaps = t.query_pairs(np.sqrt(3) - 1e-6)
    adjacent = {tuple(sorted(p)) for p in t.query_pairs(np.sqrt(3) + 1e-6)}
    kept = len(REAL & adjacent)
    print(f"{kind:18s}: {len(overlaps):3d} overlapping pairs; {kept} of {len(REAL)} real neighbour pairs adjacent ({kept / len(REAL):.0%})")
correct           :   0 overlapping pairs; 936 of 1723 real neighbour pairs adjacent (54%)
read as even-r    :   0 overlapping pairs; 731 of 1723 real neighbour pairs adjacent (42%)
C-style remainder : 580 overlapping pairs; 632 of 1723 real neighbour pairs adjacent (37%)
no offset         : 580 overlapping pairs; 632 of 1723 real neighbour pairs adjacent (37%)

Even the correct layout keeps only about half of the real neighbour pairs. A hexagon has at most six neighbours, while the 650 constituencies have 1,723 touching pairs between them, more than five each on average, including corner contacts. That is the price of a hex map, and it is the baseline the mistakes should be judged against.

Three different failures appear:

  • Reading the wrong parity (even-r for an odd-r file) gives a tidy map with no overlaps. It is still wrong: a fifth of the correct neighbour pairs are lost, because alternate rows slide against each other.
  • A C-style remainder (fmod, or % in JavaScript, C, Java and Rust) returns −1 for odd negative numbers. So r % 2 == 1 is never true in this file, no row is shifted, and pairs of hexagons in neighbouring rows overlap.
  • No offset at all gives exactly the same overlaps, which is how you recognise the remainder bug in another language.
What each offset conversion does to the UK constituency hex layout. four conversions, scoredconversionoverlapping pairsreal neighbours adjacentodd-r, Python %0936 (54%)read as even-r0731 (42%)C-style remainder580632 (37%)no offset at all580632 (37%)650 hexes; 1,723 pairs of constituencies touch spatialworkflow.io
Four conversions scored against the real constituency boundaries.

3. Fix it in any language

The portable test for an odd row is abs(r) % 2 == 1, or ((r % 2) + 2) % 2 == 1. In Python, % already returns a non-negative result for a positive divisor, so r % 2 == 1 is correct there — but the same code ported to JavaScript breaks silently:

for value in (-3, -2, 3):
    print(f"r = {value:2d}: Python {value % 2}, C/JS-style {int(np.fmod(value, 2))}, abs-based {abs(value) % 2}")
r = -3: Python 1, C/JS-style -1, abs-based 1
r = -2: Python 0, C/JS-style 0, abs-based 0
r =  3: Python 1, C/JS-style 1, abs-based 1

4. Add a test that would have caught it

def check_layout(x, y):
    t = cKDTree(np.c_[x, y])
    nn = t.query(np.c_[x, y], k=2)[0][:, 1]
    return {"overlapping pairs": len(t.query_pairs(np.sqrt(3) - 1e-6)),
            "median nearest neighbour": round(float(np.median(nn)), 3),
            "isolated hexes": int((nn > np.sqrt(3) + 1e-6).sum())}

print("correct:", check_layout(*positions("correct").T))
print("broken :", check_layout(*positions("C-style remainder").T))
correct: {'overlapping pairs': 0, 'median nearest neighbour': 1.732, 'isolated hexes': 3}
broken : {'overlapping pairs': 580, 'median nearest neighbour': 1.5, 'isolated hexes': 4}

A correct layout has no overlapping pairs and a median nearest-neighbour distance of exactly √3. The three isolated hexagons are offshore islands the layout places on purpose.

Which places move most with the wrong parity

The data used on this page

The Open Innovations HexJSON layout for the UK parliamentary constituencies used in 2024, and constituency boundaries (July 2024, ultra-generalised) from the ONS Open Geography Portal, Open Government Licence.

import json
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests
from scipy.spatial import cKDTree

Path("data").mkdir(exist_ok=True)
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/Westminster_Parliamentary_Constituencies_"
       "July_2024_Boundaries_UK_BUC/FeatureServer/0/query?where=1%3D1&outFields=PCON24CD,PCON24NM&outSR=27700&f=geojson")
for name, url in {"uk-constituencies-2023.hexjson": "https://raw.githubusercontent.com/odileeds/hexmaps/gh-pages/maps/uk-constituencies-2023.hexjson",
                  "pcon2024_buc.geojson": ONS}.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=300).content)
hexjson = json.loads(Path("data/uk-constituencies-2023.hexjson").read_text())
hexes = pd.DataFrame.from_dict(hexjson["hexes"], orient="index")
bounds = gpd.read_file("data/pcon2024_buc.geojson").set_index("PCON24CD").loc[hexes.index]
grown = bounds.geometry.buffer(200)                                      # metres: close generalisation gaps
REAL = {(i, j) for i, g in enumerate(grown) for j in grown.sindex.query(g, predicate="intersects") if j > i}
print(f"{len(hexes)} hexes; {len(REAL)} pairs of constituencies that touch")
650 hexes; 1723 pairs of constituencies that touch

Example 1: which places move most with the wrong parity

good, bad = positions("correct"), positions("read as even-r")
t_good, t_bad = cKDTree(good), cKDTree(bad)
lost = []
for i in range(len(hexes)):
    nb_good = set(t_good.query_ball_point(good[i], np.sqrt(3) + 1e-6)) - {i}
    nb_bad = set(t_bad.query_ball_point(bad[i], np.sqrt(3) + 1e-6)) - {i}
    lost.append(len(nb_good - nb_bad))
print(f"hexes with at least one neighbour changed: {np.count_nonzero(lost)} of {len(hexes)}")
print("neighbours lost per hex:", pd.Series(lost).value_counts().sort_index().to_dict())
hexes with at least one neighbour changed: 624 of 650
neighbours lost per hex: {0: 26, 1: 100, 2: 524}
Remainder of a negative row number divided by two in different languages. is row −3 odd?r = −3r = −2r = 3Python, Ruby: r % 2101C, JS, Java, Go, Rust✕−101abs(r) % 2✓101649 of 650 rows in this file are negative spatialworkflow.io
Why negative rows break the parity test in other languages; 649 of 650 rows here are negative.

Offset coordinates are not positions

Offset coordinates are a compact way to store a hexagonal grid in rows and columns, but they are not positions. Turning them into positions needs the layout type, which rows or columns are pushed, and the hexagon orientation. Get them wrong and the grid stays a grid, so nothing looks broken at a glance. A parity error moves half the hexagons sideways by half a cell. A missing offset stacks rows directly above each other, which brings hexagons in neighbouring rows closer than a touching distance.

Diagnosing a scrambled hex map from what it looks like. what does the map look like?Check overlaps and spacingpairs overlapOffset missingnegative-row parity bugtidy but wrong placesWrong parityodd-r read as even-rupside downy directionflip onceCorrect: no pair closer than √3, median spacing √3 spatialworkflow.io
Diagnosing a scrambled hex map. A correct layout has no pair closer than root 3 and a median spacing of root 3.

Negative coordinates make the parity bug likely, because remainder semantics differ between languages. Python and Ruby take the sign of the divisor; C, Java, JavaScript, Go and Rust take the sign of the dividend. Code that works on a layout with positive rows breaks on one that counts rows downward from zero, with no error. A two-line check — no pairs closer than √3, median spacing exactly √3 — catches all of these.

Pointy against flat, y direction and cube coordinates

HexJSON conversion errors and fixes. HexJSON errorsSymptomFixrows staircase and overlapabs(r) % 2 for the parity testalternate rows slideread layout: odd-r vs even-rhexes stretchedpointy: √3 × 1.5; flat: 1.5 × √3map upside downnegate y onceOpen Innovations UK constituencies layout spatialworkflow.io
HexJSON conversion errors and fixes.
  • Pointy versus flat: -r layouts use pointy-topped hexagons, -q layouts flat-topped; swap the √3 and 1.5 factors accordingly.
  • The y axis direction: in this file larger r is further north; some layouts count rows downward. Flip y if the map is upside down.
  • Cube and axial coordinates used by some hex libraries are different again; convert explicitly.
  • Islands placed off the main grid are isolated on purpose; do not "fix" them.
  • Hex size is arbitrary; any radius works if spacing scales with it.
  • SVG and canvas have y pointing down; negate y once, in one place.

FAQ

Why do the hexagons in my hex map overlap?

The row (or column) offset is not being applied, often because the oddness test fails for negative row numbers in languages where -3 % 2 is -1.

What do odd-r and even-r mean in HexJSON?

Pointy-topped hexagons in rows; in odd-r the odd rows are shifted half a column right, in even-r the even rows.

How do I test whether a negative number is odd in JavaScript?

Math.abs(r) % 2 === 1, or ((r % 2) + 2) % 2 === 1.

How can I check that my hex layout is correct?

No two hexagon centres should be closer than √3 radii, and the median nearest-neighbour distance should be exactly √3.