From 2b2e84afdd831c926ba26c81f18ed733a6f8dd8c Mon Sep 17 00:00:00 2001 From: Ben Best Date: Tue, 21 Apr 2026 23:40:25 +0200 Subject: [PATCH 1/6] fix(h3t): return empty pbf when tile has no features (v0.9.3) 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) --- package.json | 2 +- src/index.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 12720ce..3b785d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "h3j-h3t", - "version": "0.9.2", + "version": "0.9.3", "author": { "name": "Abel Vázquez Montoro", "email": "abelvazquez@inspide.com", diff --git a/src/index.js b/src/index.js index e4d58fa..3c5b0e0 100644 --- a/src/index.js +++ b/src/index.js @@ -79,6 +79,16 @@ const h3tsource = function (name, options) { .then(js => h3jparser(js, o)) .then(g => { const f = utils.tovt(g).getTile(...zxy); + // getTile() returns null when no features land in this tile (e.g. an + // ocean tile when data is coastal). Passing null into vt-pbf throws + // "Cannot read properties of null" which MapLibre then surfaces as + // "Cannot read properties of undefined (reading 'data')". Return an + // empty pbf instead — represents a valid but feature-less vector tile. + if (!f) { + if (!!o.debug) console.log(`${zxy}: 0 features (empty tile), ${(performance.now() - t).toFixed(0)} ms`); + callback(null, new Uint8Array(0), null, null); + return; + } const fo = {}; fo[o.sourcelayer] = f; const From ea93fe9b71adfc23f9bab4e9a0d5b1537a833194 Mon Sep 17 00:00:00 2001 From: Ben Best Date: Wed, 22 Apr 2026 00:19:24 +0200 Subject: [PATCH 2/6] feat(h3t): support MapLibre GL JS v3+/v4 promise protocol API (v0.9.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 2 +- src/index.js | 52 +++++++++++++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 3b785d8..f366333 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "h3j-h3t", - "version": "0.9.3", + "version": "0.9.4", "author": { "name": "Abel Vázquez Montoro", "email": "abelvazquez@inspide.com", diff --git a/src/index.js b/src/index.js index 3c5b0e0..758f0cb 100644 --- a/src/index.js +++ b/src/index.js @@ -58,16 +58,25 @@ const h3tsource = function (name, options) { const o = Object.assign({}, defaults, options, { "type": 'vector', "format": 'pbf' }); o.generate = h3id => (o.geometry_type === 'Polygon') ? [utils.h3.h3ToGeoBoundary(h3id, true)] : utils.h3.h3ToGeo(h3id).reverse(); if (!!o.promoteId) o.promoteId = 'h3id'; - lib.addProtocol('h3tiles', (params, callback) => { + // MapLibre GL JS v3+/v4 uses a promise-returning protocol handler signature: + // addProtocol(scheme, (params, abortController) => Promise<{data, cacheControl?, expires?}>) + // Older v2 used callback style. We support BOTH: if the 2nd arg is a function + // we assume callback style; otherwise treat it as an AbortController and + // return a Promise. + lib.addProtocol('h3tiles', (params, cbOrCtl) => { + const isPromiseAPI = typeof cbOrCtl !== 'function'; const u = `http${(o.https === false) ? '' : 's'}://${params.url.split('://')[1]}`; const s = params.url.split(/\/|\./i); const l = s.length; const zxy = s.slice(l - 4, l - 1).map(k => k * 1); - const controller = new AbortController(); + const controller = (isPromiseAPI && cbOrCtl && cbOrCtl.signal) + ? cbOrCtl + : new AbortController(); const signal = controller.signal; let t; if (o.timeout > 0) setTimeout(() => controller.abort(), o.timeout); - fetch(u, { signal }) + + const buildTile = () => fetch(u, { signal }) .then(r => { if (r.ok) { t = performance.now(); @@ -80,29 +89,38 @@ const h3tsource = function (name, options) { .then(g => { const f = utils.tovt(g).getTile(...zxy); // getTile() returns null when no features land in this tile (e.g. an - // ocean tile when data is coastal). Passing null into vt-pbf throws - // "Cannot read properties of null" which MapLibre then surfaces as - // "Cannot read properties of undefined (reading 'data')". Return an - // empty pbf instead — represents a valid but feature-less vector tile. + // ocean tile when data is coastal). Return an empty but valid MVT + // instead of tripping vt-pbf on null. if (!f) { if (!!o.debug) console.log(`${zxy}: 0 features (empty tile), ${(performance.now() - t).toFixed(0)} ms`); - callback(null, new Uint8Array(0), null, null); - return; + return new Uint8Array(0); } const fo = {}; fo[o.sourcelayer] = f; - const - p = utils.topbf.fromGeojsonVt( - fo, - { "version": 2 } - ); + const p = utils.topbf.fromGeojsonVt(fo, { "version": 2 }); if (!!o.debug) console.log(`${zxy}: ${g.features.length} features, ${(performance.now() - t).toFixed(0)} ms`); - callback(null, p, null, null); - }) + return p; + }); + + if (isPromiseAPI) { + // v3+/v4 promise API: return { data, cacheControl?, expires? } + return buildTile() + .then(data => ({ data })) + .catch(e => { + if (e.name === 'AbortError') { + e = new Error(`Timeout: Tile .../${zxy.join('/')}.h3t is taking too long to fetch`); + } + throw e; + }); + } + // v2 callback API + buildTile() + .then(data => cbOrCtl(null, data, null, null)) .catch(e => { if (e.name === 'AbortError') e.message = `Timeout: Tile .../${zxy.join('/')}.h3t is taking too long to fetch`; - callback(new Error(e)); + cbOrCtl(e); }); + return { cancel: () => controller.abort() }; }); this.addSource(name, vtclean(o)); }; From 18c8edc2c8d818bc71040cdb4301edae33c01664 Mon Sep 17 00:00:00 2001 From: Ben Best Date: Wed, 22 Apr 2026 00:49:18 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(h3t):=20v0.9.5=20=E2=80=94=20return=20?= =?UTF-8?q?ArrayBuffer,=20add=20debug=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package.json | 2 +- src/index.js | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f366333..b2ec287 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "h3j-h3t", - "version": "0.9.4", + "version": "0.9.5", "author": { "name": "Abel Vázquez Montoro", "email": "abelvazquez@inspide.com", diff --git a/src/index.js b/src/index.js index 758f0cb..bdb85ef 100644 --- a/src/index.js +++ b/src/index.js @@ -92,14 +92,17 @@ const h3tsource = function (name, options) { // ocean tile when data is coastal). Return an empty but valid MVT // instead of tripping vt-pbf on null. if (!f) { - if (!!o.debug) console.log(`${zxy}: 0 features (empty tile), ${(performance.now() - t).toFixed(0)} ms`); - return new Uint8Array(0); + if (!!o.debug) console.log(`[h3t] ${zxy}: 0 features (empty tile), ${(performance.now() - t).toFixed(0)} ms`); + // return as ArrayBuffer (not Uint8Array) so MapLibre v5 accepts it + return new ArrayBuffer(0); } const fo = {}; fo[o.sourcelayer] = f; const p = utils.topbf.fromGeojsonVt(fo, { "version": 2 }); - if (!!o.debug) console.log(`${zxy}: ${g.features.length} features, ${(performance.now() - t).toFixed(0)} ms`); - return p; + if (!!o.debug) console.log(`[h3t] ${zxy}: ${g.features.length} feats (g), ${f.features ? f.features.length : '?'} feats (tile), sourcelayer=${o.sourcelayer}, ${p.byteLength}B, ${(performance.now() - t).toFixed(0)} ms`); + // vt-pbf returns a Uint8Array; MapLibre's promise-API expects the raw + // buffer. Give it ArrayBuffer to be safe across versions. + return p.buffer ? p.buffer.slice(p.byteOffset, p.byteOffset + p.byteLength) : p; }); if (isPromiseAPI) { From cb8dbdc0586bd4d63c8c735ac0f2868668f7b9c7 Mon Sep 17 00:00:00 2001 From: Ben Best Date: Wed, 22 Apr 2026 00:56:16 +0200 Subject: [PATCH 4/6] fix(h3t): robust z/x/y parsing from URL paths containing query strings (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 '///.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) --- package.json | 2 +- src/index.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b2ec287..d5bfea0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "h3j-h3t", - "version": "0.9.5", + "version": "0.9.6", "author": { "name": "Abel Vázquez Montoro", "email": "abelvazquez@inspide.com", diff --git a/src/index.js b/src/index.js index bdb85ef..5da509f 100644 --- a/src/index.js +++ b/src/index.js @@ -66,9 +66,20 @@ const h3tsource = function (name, options) { lib.addProtocol('h3tiles', (params, cbOrCtl) => { const isPromiseAPI = typeof cbOrCtl !== 'function'; const u = `http${(o.https === false) ? '' : 's'}://${params.url.split('://')[1]}`; - const s = params.url.split(/\/|\./i); - const l = s.length; - const zxy = s.slice(l - 4, l - 1).map(k => k * 1); + // Extract z/x/y from the URL path. The previous implementation split on + // both "/" and "." which breaks when the URL carries a query string with + // dots (e.g. ?release=v2026.04.08) — it would read "NaN" as the x coord + // and every tile came back empty. Match the path segment "...///.h3t" + // explicitly before the query string. + const pathOnly = params.url.split('?')[0]; + const m = pathOnly.match(/\/(\d+)\/(\d+)\/(\d+)\.h3t$/); + if (!m) { + const err = new Error(`h3t: cannot parse {z}/{x}/{y} from URL: ${params.url}`); + if (isPromiseAPI) return Promise.reject(err); + cbOrCtl(err); + return { cancel: () => {} }; + } + const zxy = [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]; const controller = (isPromiseAPI && cbOrCtl && cbOrCtl.signal) ? cbOrCtl : new AbortController(); From bd880d210675fe4041d13123034480e43297699e Mon Sep 17 00:00:00 2001 From: Ben Best Date: Wed, 22 Apr 2026 01:13:57 +0200 Subject: [PATCH 5/6] fix(h3t): unique scheme per source to avoid global protocol collision (v0.9.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 2 +- src/index.js | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d5bfea0..3524022 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "h3j-h3t", - "version": "0.9.6", + "version": "0.9.7", "author": { "name": "Abel Vázquez Montoro", "email": "abelvazquez@inspide.com", diff --git a/src/index.js b/src/index.js index 5da509f..4ca9239 100644 --- a/src/index.js +++ b/src/index.js @@ -58,12 +58,30 @@ const h3tsource = function (name, options) { const o = Object.assign({}, defaults, options, { "type": 'vector', "format": 'pbf' }); o.generate = h3id => (o.geometry_type === 'Polygon') ? [utils.h3.h3ToGeoBoundary(h3id, true)] : utils.h3.h3ToGeo(h3id).reverse(); if (!!o.promoteId) o.promoteId = 'h3id'; + // MapLibre's addProtocol registry is GLOBAL — last registration of a given + // scheme wins. When two maps (or two sources on one map) both register + // 'h3tiles', the second overwrites the first, so the first source's + // closure is dead and its layer renders with the wrong sourcelayer + // (the surviving closure's). Give each source its own unique scheme so + // both closures coexist. + lib.__h3tSchemeCounter = (lib.__h3tSchemeCounter || 0) + 1; + const scheme = `h3t${lib.__h3tSchemeCounter}`; + // rewrite tile templates from h3tiles:// to h3tN:// so MapLibre dispatches + // to this source's dedicated handler. + if (Array.isArray(o.tiles)) { + o.tiles = o.tiles.map(t => + typeof t === 'string' && t.startsWith('h3tiles://') + ? scheme + '://' + t.slice('h3tiles://'.length) + : t + ); + } + if (!!o.debug) console.log(`[h3t] addSource "${name}" → scheme "${scheme}", sourcelayer="${o.sourcelayer}"`); // MapLibre GL JS v3+/v4 uses a promise-returning protocol handler signature: // addProtocol(scheme, (params, abortController) => Promise<{data, cacheControl?, expires?}>) // Older v2 used callback style. We support BOTH: if the 2nd arg is a function // we assume callback style; otherwise treat it as an AbortController and // return a Promise. - lib.addProtocol('h3tiles', (params, cbOrCtl) => { + lib.addProtocol(scheme, (params, cbOrCtl) => { const isPromiseAPI = typeof cbOrCtl !== 'function'; const u = `http${(o.https === false) ? '' : 's'}://${params.url.split('://')[1]}`; // Extract z/x/y from the URL path. The previous implementation split on From 8bd3031729e26bf0c48695d9d1a1a470df56c417 Mon Sep 17 00:00:00 2001 From: Ben Best Date: Wed, 24 Jun 2026 13:39:40 +0200 Subject: [PATCH 6/6] fix: render H3 cells crossing the antimeridian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/index.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 4ca9239..dedc80c 100644 --- a/src/index.js +++ b/src/index.js @@ -56,7 +56,21 @@ const filterObject = (obj, callback) => { */ const h3tsource = function (name, options) { const o = Object.assign({}, defaults, options, { "type": 'vector', "format": 'pbf' }); - o.generate = h3id => (o.geometry_type === 'Polygon') ? [utils.h3.h3ToGeoBoundary(h3id, true)] : utils.h3.h3ToGeo(h3id).reverse(); + o.generate = h3id => { + if (o.geometry_type !== 'Polygon') return utils.h3.h3ToGeo(h3id).reverse(); + const ring = utils.h3.h3ToGeoBoundary(h3id, true); + // Nick Rabinowitz fixTransmeridian (https://observablehq.com/@nrabinowitz/mapbox-utils): + // a ring with any arc > 180° of longitude is transmeridian; shift its + // negative-longitude vertices by +360° so it stays continuous near +180° + // instead of wrapping the globe. + for (let i = 0; i < ring.length; i++) { + if (Math.abs(ring[0][0] - ring[(i + 1) % ring.length][0]) > 180) { + for (const c of ring) if (c[0] < 0) c[0] += 360; + break; + } + } + return [ring]; + }; if (!!o.promoteId) o.promoteId = 'h3id'; // MapLibre's addProtocol registry is GLOBAL — last registration of a given // scheme wins. When two maps (or two sources on one map) both register @@ -116,6 +130,22 @@ const h3tsource = function (name, options) { }) .then(js => h3jparser(js, o)) .then(g => { + // Tiled rendering: a cell straddling ±180° is fetched into more than + // one {z}/{x}/{y} tile (the server returns it to both edge tiles). Place + // each cell on the side of the antimeridian THIS tile sits on, so its + // two halves draw in their respective edge tiles and meet at 180°. + const tileLng = (zxy[1] + 0.5) / (1 << zxy[0]) * 360 - 180; + for (const ft of g.features) { + if (ft.geometry.type !== 'Polygon') continue; + const r = ft.geometry.coordinates[0]; + let s = 0; const ref = r[0][0]; + while (ref + s - tileLng > 180) s -= 360; + while (ref + s - tileLng < -180) s += 360; + // rebuild (not mutate): h3ToGeoBoundary returns a CLOSED ring whose + // first and last points are the same object — an in-place += would + // shift that shared vertex twice and fling it ~360° off (a sliver). + if (s) ft.geometry.coordinates[0] = r.map(c => [c[0] + s, c[1]]); + } const f = utils.tovt(g).getTile(...zxy); // getTile() returns null when no features land in this tile (e.g. an // ocean tile when data is coastal). Return an empty but valid MVT @@ -171,7 +201,21 @@ const h3jsource = function (name, options) { const signal = controller.signal; const o = Object.assign({}, defaults, options, { "type": 'geojson' }); let t; - o.generate = h3id => (o.geometry_type === 'Polygon') ? [utils.h3.h3ToGeoBoundary(h3id, true)] : utils.h3.h3ToGeo(h3id).reverse(); + o.generate = h3id => { + if (o.geometry_type !== 'Polygon') return utils.h3.h3ToGeo(h3id).reverse(); + const ring = utils.h3.h3ToGeoBoundary(h3id, true); + // Nick Rabinowitz fixTransmeridian (https://observablehq.com/@nrabinowitz/mapbox-utils): + // a ring with any arc > 180° of longitude is transmeridian; shift its + // negative-longitude vertices by +360° so it stays continuous near +180° + // instead of wrapping the globe. + for (let i = 0; i < ring.length; i++) { + if (Math.abs(ring[0][0] - ring[(i + 1) % ring.length][0]) > 180) { + for (const c of ring) if (c[0] < 0) c[0] += 360; + break; + } + } + return [ring]; + }; if (!!o.promoteId) o.promoteId = 'h3id'; if (o.timeout > 0) setTimeout(() => controller.abort(), o.timeout); if (typeof o.data === 'string') { @@ -217,7 +261,21 @@ lib.Map.prototype.addH3JSource = h3jsource; */ const h3jsetdata = function (name, data, options) { const o = Object.assign({}, defaults, options); - o.generate = h3id => (o.geometry_type === 'Polygon') ? [utils.h3.h3ToGeoBoundary(h3id, true)] : utils.h3.h3ToGeo(h3id).reverse(); + o.generate = h3id => { + if (o.geometry_type !== 'Polygon') return utils.h3.h3ToGeo(h3id).reverse(); + const ring = utils.h3.h3ToGeoBoundary(h3id, true); + // Nick Rabinowitz fixTransmeridian (https://observablehq.com/@nrabinowitz/mapbox-utils): + // a ring with any arc > 180° of longitude is transmeridian; shift its + // negative-longitude vertices by +360° so it stays continuous near +180° + // instead of wrapping the globe. + for (let i = 0; i < ring.length; i++) { + if (Math.abs(ring[0][0] - ring[(i + 1) % ring.length][0]) > 180) { + for (const c of ring) if (c[0] < 0) c[0] += 360; + break; + } + } + return [ring]; + }; if (!!o.promoteId) o.promoteId = 'h3id'; const controller = new AbortController(); const signal = controller.signal;