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
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-rfor anodd-rfile) 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. Sor % 2 == 1is 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.
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}
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.
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
- Pointy versus flat:
-rlayouts use pointy-topped hexagons,-qlayouts flat-topped; swap the √3 and 1.5 factors accordingly. - The y axis direction: in this file larger
ris 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.
Internal links
- How to make a UK election hex map — the layout drawn correctly
- Hex and tile maps explained — what hex maps are for
- H3 hexagons break at the antimeridian — a different hexagon system
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.