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 idAliasesRepresentsCellsJoin key
us/states@hexus/hex, us/states/hexus/states@10m51abbr
jp/admin1@hexjp/hex, jp/prefectures/hexjp/admin1@10m47iso_3166_2
eu/nuts0@hexeu/hex, eu/countries/hexeu/nuts0@20m37nuts_id
br/admin1@hexbr/hex, br/states/hexbr/admin1@10m27iso_3166_2
de/admin1@hexde/hex, de/states/hexde/admin1@10m16iso_3166_2
ca/admin1@hexca/hex, ca/provinces/hexca/admin1@10m13iso_3166_2
au/admin1@hexau/hex, au/states/hexau/admin1@10m8iso_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.

WhatWhyOverride with
The identity projectionThe coordinates are a grid, not longitude and latitude, so projecting them would be meaninglessgeo.projection
Labels are join keys, not namesA 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 fightsdataLabels.field
Zoom and pan default offNothing sharpens on zoom and there is nothing off-screen, so both gestures go back to the pageinteraction.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' }
FieldTypeWhat it is
keyFieldstringGeometry field the cells are keyed by. Must match the boundary pack's. Required
cellsRecord<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
gapnumberSpace between cells as a fraction of the cell. Defaults to 0.06
namesRecord<string, string>Region names, so a layout used without its boundary pack still has something for a tooltip
nameFieldstringWhere names is written. Defaults to 'name'
unplacedstring[]Boundary-pack keys this layout deliberately leaves out
compromisesstring[]Known departures from real geography, for the record
ofstringCanonical 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.

Premium feature

Hex tile layouts is a Premium feature

Available on the Premium and OEM plans. Layouts work without a key for evaluation, with a watermark on the map; call ApexMaps.setLicense(key) to remove it. The gate is on the representation, so naming a layout id directly and registering your own table are the same feature, gated the same way. Registering is always free; rendering is what is licensed.

Not the same thing as a hexbin

Two features draw hexagons and they are unrelated:

layout: 'hex'type: 'hexbin'
A cell isone region, placed by handone patch of the projection
It needsa region set with a layoutpoints with coordinates
Cell countfixed, however many regions there arewhatever the data and the radius give
Boundariesreplaced by itignored by it, so it sits on a basemap
Reading itwhich region, at equal weighthow much landed where

See also