Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "h3j-h3t",
"version": "0.9.2",
"version": "0.9.7",
"author": {
"name": "Abel Vázquez Montoro",
"email": "abelvazquez@inspide.com",
Expand Down
154 changes: 136 additions & 18 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,70 @@ 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';
lib.addProtocol('h3tiles', (params, callback) => {
// 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(scheme, (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();
// 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 ".../<z>/<x>/<y>.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();
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();
Expand All @@ -78,21 +130,59 @@ 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
// instead of tripping vt-pbf on null.
if (!f) {
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`);
callback(null, p, null, null);
})
const p = utils.topbf.fromGeojsonVt(fo, { "version": 2 });
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) {
// 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));
};
Expand All @@ -111,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') {
Expand Down Expand Up @@ -157,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;
Expand Down