Hex Tile Layouts
A choropleth paints real boundaries, and real boundaries carry an argument nobody chose: area. Most of a US map's ink goes to states holding a small share of its people, so the same value shouts in Montana and is a speck in Rhode Island. A hex tile map (a honeycomb, or tilegram) gives every region one cell of the same size, trading a distorted map for a legible one.
const map = new ApexMaps(element, {
geo: { map: 'us', layout: 'hex' },
series: [{ type: 'choropleth', name: 'Index', joinBy: 'abbr', data }],
})
layout is a property of the geometry, not of the series. The cells are keyed exactly the way the boundary pack is keyed, so the same data and the same joinBy serve both representations, and the scale, legend, tooltip, labels, selection and keyboard navigation are the machinery that already exists.
Three ways to reach one
geo: { map: 'us', layout: 'hex' } // toggleable, and what most callers want
geo: { map: 'us/states@hex' } // named directly; 'us/hex' aliases to it
ApexMaps.registerLayout(id, table, meta) // your own cell table
The layout: 'hex' form is the one to reach for, because it is the only one that can be turned off again, and turning it off is what produces the morph. Naming the id directly is for a map that is only ever a honeycomb.
A layout resolves independently of the boundaries it represents and is roughly fifty times smaller, so asking for the honeycomb never downloads geometry it does not draw. A region set with no layout is an error rather than a silent fall back to real boundaries, which would look like the option had been ignored.
The seven that ship
| Layout id | Aliases | Represents | Cells | Join key |
|---|---|---|---|---|
us/states@hex | us/hex, us/states/hex | us/states@10m | 51 | abbr |
jp/admin1@hex | jp/hex, jp/prefectures/hex | jp/admin1@10m | 47 | iso_3166_2 |
eu/nuts0@hex | eu/hex, eu/countries/hex | eu/nuts0@20m | 37 | nuts_id |
br/admin1@hex | br/hex, br/states/hex | br/admin1@10m | 27 | iso_3166_2 |
de/admin1@hex | de/hex, de/states/hex | de/admin1@10m | 16 | iso_3166_2 |
ca/admin1@hex | ca/hex, ca/provinces/hex | ca/admin1@10m | 13 | iso_3166_2 |
au/admin1@hex | au/hex, au/states/hex | au/admin1@10m | 8 | iso_3166_2 |
Each is keyed the way the boundary pack it represents is keyed, not on a scheme of its own, so switching representation is an option change rather than a data migration:
// One dataset. Both maps read it the same way.
const series = [{ name: 'Index', joinBy: ['abbr', 'key'], data }]
new ApexMaps(a, { geo: { map: 'us' }, series }) // real boundaries
new ApexMaps(b, { geo: { map: 'us', layout: 'hex' }, series }) // one cell per state
ApexMaps.mapMeta('us/states@hex') reports the layout's own metadata, including layout.of (the boundary pack it claims to represent) and layout.unplaced.
The morph back to geography
A cartogram has one hard problem: a cell is unlabelled geography until the reader learns which cell is which place. Toggling layout through updateOptions walks each region between its outline and its cell instead of swapping them.
await map.updateOptions({ geo: { map: 'us', layout: 'hex' } }) // morphs
await map.updateOptions({ geo: { map: 'us', layout: null } }) // morphs back
There is no option to enable it. Three things decide whether it reads as a morph or as a glitch, and all three are correspondence rather than timing: each outline is resampled at equal arc length (a hexagon has six vertices, a mainland outline has hundreds), rotated to the cyclic offset that best matches its target so regions do not spin, and checked for winding, because the two representations do not share a projection and opposite winding turns a shape inside out halfway across.
It runs off chart.animations, so animations: { enabled: false } turns it off with everything else, and it stands down on its own above the motion budget, where per-frame vertex work would cost frames. It is skipped when the region set itself changes, since morphing one country's regions into another's is not information.
Labels step out for the flight and return when the geometry arrives, because they are placed against geometry that has settled.
Three defaults a layout sets for you
A layout knows it is a diagram, so the pack recommends what a diagram needs. Each of these is a default, not a rule.
| What | Why | Override with |
|---|---|---|
The identity projection | The coordinates are a grid, not longitude and latitude, so projecting them would be meaningless | geo.projection |
| Labels are join keys, not names | A cell sized for the smallest region cannot hold a long name. On the US layout, keys fit in all 51 cells where names lose most of their collision fights | dataLabels.field |
| Zoom and pan default off | Nothing sharpens on zoom and there is nothing off-screen, so both gestures go back to the page | interaction.zoom.enabled, interaction.pan.enabled |
Cells are small, so labels usually want the collision guard relaxed and the area floor dropped:
dataLabels: { enabled: true, minFeatureArea: 0, collision: 'none' }
Author your own
ApexMaps.registerLayout(id, pack, meta?) takes a table of key -> [col, row], row 0 north and col 0 west, keyed by whatever field your boundary pack joins on.
ApexMaps.registerLayout('nl/provinces@hex', {
keyField: 'code',
cells: { 'NL-GR': [4, 0], 'NL-FR': [3, 1], 'NL-DR': [4, 1] },
names: { 'NL-GR': 'Groningen', 'NL-FR': 'Friesland', 'NL-DR': 'Drenthe' },
})
// geo: { map: 'nl/provinces@hex' }
| Field | Type | What it is |
|---|---|---|
keyField | string | Geometry field the cells are keyed by. Must match the boundary pack's. Required |
cells | Record<string, [col, row]> | The table. Row 0 north, col 0 west. Required |
grid | 'hex' | 'square' | Cell shape. Defaults to 'hex' |
orientation | 'pointy' | 'flat' | A vertex up, or a vertex to the side. Defaults to 'pointy' |
offset | 'odd-r' | 'even-r' | 'odd-q' | 'even-q' | Which rows or columns are indented. Defaults to 'odd-r' for pointy, 'odd-q' for flat |
gap | number | Space between cells as a fraction of the cell. Defaults to 0.06 |
names | Record<string, string> | Region names, so a layout used without its boundary pack still has something for a tooltip |
nameField | string | Where names is written. Defaults to 'name' |
unplaced | string[] | Boundary-pack keys this layout deliberately leaves out |
compromises | string[] | Known departures from real geography, for the record |
of | string | Canonical id of the boundary pack whose keys these cells use |
Only two orientation and offset combinations are coherent: pointy-top cells offset by row, flat-top cells offset by column. A pack asking for anything else is rejected rather than reinterpreted, because reading an odd-r table as even-r shifts half the rows by half a cell and still looks like a plausible honeycomb. Two regions on one cell and a cell that is not [col, row] are rejected for the same reason: all three render something plausible and wrong.
gap belongs to the pack rather than to the render call because the generated geometry is cached per file. A gap chosen at render time would either be ignored by the second map on the page or force a second copy of the geometry.
Square grids are supported here through grid: 'square'. layout: 'hex' is the only value the geo.layout shorthand accepts, because a hex layout is the only kind that ships built in.
Curating a table is the work
There is no canonical layout for any country, so a good one is a judgement about which real adjacencies matter most. Two things make that judgement checkable.
Unplaced regions are declared, not missing. The US layout omits the five inhabited territories that the boundary pack carries, because no published one-hex-per-unit layout includes them. Declaring them in unplaced is what lets a coverage warning tell a reader that a region was left out on purpose rather than lost on the way in. Render with debug on to see the coverage report.
Compromises are recorded. Every layout has a shape that fights the grid, and the useful thing to know is which compromise it forced. Berlin sits beside Brandenburg because the two share a centroid and no grid holds an enclave. Nunavut keeps its land border with the Northwest Territories and gives up its offshore one, because one cell cannot reach 60 degrees of longitude. Nagano borders eight prefectures and a hexagon has six sides, so six is its ceiling. Recording these in compromises keeps a decision already taken from hiding a new mistake.
Not the same thing as a hexbin
Two features draw hexagons and they are unrelated:
layout: 'hex' | type: 'hexbin' | |
|---|---|---|
| A cell is | one region, placed by hand | one patch of the projection |
| It needs | a region set with a layout | points with coordinates |
| Cell count | fixed, however many regions there are | whatever the data and the radius give |
| Boundaries | replaced by it | ignored by it, so it sits on a basemap |
| Reading it | which region, at equal weight | how much landed where |
See also
- Geometry Registry for the boundary packs each layout represents
- Choropleth for the series a layout is usually drawn with
- Hexbin for the other hexagons
- Legends, Labels & Tooltips for
dataLabelson small cells