Skip to content

fix: render H3 cells crossing the antimeridian - #9

Open
bbest wants to merge 6 commits into
INSPIDE:mainfrom
bbest:fix/antimeridian
Open

fix: render H3 cells crossing the antimeridian#9
bbest wants to merge 6 commits into
INSPIDE:mainfrom
bbest:fix/antimeridian

Conversation

@bbest

@bbest bbest commented Jun 24, 2026

Copy link
Copy Markdown

Cells straddling ±180° rendered as a tear/gap (h3ToGeoBoundary() splits vertices +179/−179 → ~358° polygon → geojson-vt mis-tiles).

generate() now applies Nick Rabinowitz's fixTransmeridian: a ring with any arc > 180° lon has its negative-lon vertices shifted +360°, staying continuous near +180°. Complete for the non-tiled addH3JSource / setH3JData.

h3tsource handler: for the tiled path, a crossing cell is fetched into more than one {z}/{x}/{y} tile, so each cell is normalized by ±360° onto the side of the antimeridian the rendered tile sits on — its halves then draw in their respective edge tiles and meet at 180°. Pairs with an antimeridian-aware tile filter on the h3t server (returns a crossing cell to both edge tiles; e.g. CalCOFI/api-h3t-py#1).

src/index.js only (dist/ is gitignored / rebuilt on publish). No new deps. Also carried in walkerke/mapgl#211 (which vendors this bundle).

bbest and others added 6 commits April 21, 2026 23:40
When a client pans to a tile whose bbox contains no H3 cells from the
source (e.g. open ocean for a coastal dataset), geojson-vt's getTile()
returns null. Passing null into vt-pbf.fromGeojsonVt() throws, and the
promise rejection reaches MapLibre as a mangled
"Cannot read properties of undefined (reading 'data')" error that
prevents any subsequent tiles from rendering on the layer.

Short-circuit empty tiles with callback(null, new Uint8Array(0)), which
is a valid empty vector tile that MapLibre handles as "this tile has
no features" without going through the error path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MapLibre GL JS v3 switched addProtocol's handler signature from
callback-style `(params, callback) => { callback(null, data) }` to
promise-style `(params, abortController) => Promise<{data}>`. MapLibre
GL JS v5 removed the callback signature entirely.

Previously the h3tsource handler assumed callback style. On v3+/v4
MapLibre invokes the handler as `e(params, abortController)` — trying
to call the AbortController as a function threw
"Uncaught (in promise) TypeError: e is not a function", which
propagated through MapLibre as
"Cannot read properties of undefined (reading 'data')" and tore down
the layer.

Handler now:
- Detects signature via typeof cbOrCtl === 'function'
- Reuses the caller-supplied AbortController on v3+, avoiding
  double abort-timer setup
- Returns a Promise<{data}> for v3+/v4/v5
- Keeps the callback call for v2 compatibility
- Preserves the empty-tile short-circuit from v0.9.3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- MapLibre GL JS v3+/v4/v5 promise API accepts Uint8Array OR ArrayBuffer
  per docs, but some internal code paths assume ArrayBuffer. Convert the
  vt-pbf Uint8Array output via .buffer.slice() to be explicit.
- Empty-tile return: ArrayBuffer(0) instead of Uint8Array(0).
- debug: true now logs tile zxy, feature count, source-layer name, and
  byte size so we can diagnose tile-decode issues from the browser
  console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s (v0.9.6)

The original parser split the whole URL on both '/' and '.' and took the
last 4 segments as [z, x, y, 'h3t']. That works for plain URLs like
.../5/5/12.h3t but breaks when a query string with dots is appended:

  .../5/5/12.h3t?q=...&release=v2026.04.08
  split(/\/|\./) -> ['...', '5', '5', '12', 'h3t?q=...&release=v2026', '04', '08']
  slice(-4, -1)  -> ['12', 'h3t?q=...&release=v2026', '04']
  * 1           -> [12, NaN, 4]

getTile(z, NaN, y) returns nothing, every tile came back empty, and no
hexagons rendered in the client even though the HTTP response was valid.

Fix: strip the query string, then match '/<z>/<x>/<y>.h3t$' with a
regex. Reject with a clear error if the path doesn't match instead of
silently serving empty tiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (v0.9.7)

MapLibre's addProtocol() registers into a window-level map:
    REGISTERED_PROTOCOLS[scheme] = handler
so the last registration of a given scheme wins. When two sources
(e.g. two sides of a compare widget) both called addH3TSource(),
the second one clobbered the first's closure — including its
sourcelayer name. The surviving closure would then emit tiles with
the wrong sourcelayer, so only ONE side's layer ever found features
to render. Which side won depended on the race between the two
map 'load' events (hence the flip-flop on reload).

Fix: mint a unique scheme per source ('h3t1', 'h3t2', ...) and
rewrite h3tiles:// in the tile template to match. Each closure now
lives under its own key, so they coexist and both sides render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cells straddling ±180° rendered as a tear/gap: h3ToGeoBoundary() returns their
vertices split between +179 and -179, so the polygon spans ~358° of longitude
and geojson-vt mis-tiles it.

- generate(): apply Nick Rabinowitz fixTransmeridian
  (https://observablehq.com/@nrabinowitz/mapbox-utils) — a ring with any arc
  > 180° lon has its negative-lon vertices shifted +360°, staying continuous
  near +180° instead of wrapping the globe. Complete for the geojson
  (addH3JSource / setH3JData) path.

- h3tsource handler: for the tiled vector path, a crossing cell is fetched into
  more than one {z}/{x}/{y} tile; normalize each cell by ±360° onto the side of
  the antimeridian the rendered tile sits on, so its halves draw in their
  respective edge tiles and meet at 180°. Pairs with an antimeridian-aware tile
  filter on the h3t server (returns a crossing cell to both edge tiles).

No new dependencies; non-crossing cells are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bbest
bbest force-pushed the fix/antimeridian branch from 6f05f5f to 8bd3031 Compare June 24, 2026 12:20
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.

1 participant