diff --git a/package.json b/package.json index 12720ce..3524022 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.js b/src/index.js index e4d58fa..dedc80c 100644 --- a/src/index.js +++ b/src/index.js @@ -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 "...///.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(); @@ -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)); }; @@ -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') { @@ -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;