diff --git a/package.json b/package.json index 1f008be..7bcf1b1 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "node": ">=14" }, "dependencies": { - "@mapbox/tile-cover": "3.0.1" + "@mapbox/tile-cover": "3.0.1", + "@math.gl/web-mercator": "^4.1.0" }, "packageManager": "yarn@1.22.22", "volta": { diff --git a/src/index.ts b/src/index.ts index 8cdfd42..73a637e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import {tiles} from '@mapbox/tile-cover'; +import {worldToLngLat} from '@math.gl/web-mercator'; import type {Polygon} from 'geojson'; const B = [ @@ -14,25 +15,27 @@ const S = [0n, 1n, 2n, 4n, 8n, 16n]; type Quadbin = bigint; type Tile = {x: number; y: number; z: number}; -function tileToLongitude(tile: ReturnType, offset: number) { - const {x, z} = tile; - return 180 * ((2.0 * (x + offset)) / (1 << z) - 1.0); -} +const TILE_SIZE = 512; -function tileToLatitude(tile: ReturnType, offset: number) { - const {y, z} = tile; - const expy = Math.exp(-((2.0 * (y + offset)) / (1 << z) - 1) * Math.PI); - return 360 * (Math.atan(expy) / Math.PI - 0.25); +function quadbinToOffset(quadbin: bigint): [number, number, number] { + const {x, y, z} = cellToTile(quadbin); + const scale = TILE_SIZE / (1 << z); + return [x * scale, TILE_SIZE - y * scale, scale]; } -function cellToBoundingBox(cell: bigint) { - const tile = cellToTile(cell); - const xmin = tileToLongitude(tile, 0); - const xmax = tileToLongitude(tile, 1); - const ymin = tileToLatitude(tile, 1); - const ymax = tileToLatitude(tile, 0); +function quadbinToWorldBounds(quadbin: bigint, coverage: number): [number[], number[]] { + const [xOffset, yOffset, scale] = quadbinToOffset(quadbin); + return [ + [xOffset, yOffset], + [xOffset + coverage * scale, yOffset - coverage * scale] + ]; +} - return [xmin, ymin, xmax, ymax]; +function getQuadbinPolygon(quadbin: bigint, coverage = 1): number[] { + const [topLeft, bottomRight] = quadbinToWorldBounds(quadbin, coverage); + const [w, n] = worldToLngLat(topLeft); + const [e, s] = worldToLngLat(bottomRight); + return [e, n, e, s, w, s, w, n, e, n]; } export function hexToBigInt(hex: string): bigint { @@ -113,13 +116,13 @@ export function geometryToCells(geometry, resolution: bigint): Quadbin[] { } export function cellToBoundary(cell: bigint): Polygon { - const bbox = cellToBoundingBox(cell); + const bbox = getQuadbinPolygon(cell); const boundary = [ - [bbox[0], bbox[3]], [bbox[0], bbox[1]], - [bbox[2], bbox[1]], [bbox[2], bbox[3]], - [bbox[0], bbox[3]] + [bbox[4], bbox[5]], + [bbox[6], bbox[7]], + [bbox[0], bbox[1]] ]; return {type: 'Polygon', coordinates: [boundary]}; diff --git a/test/index.spec.js b/test/index.spec.js index d634d32..68d9a55 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -80,30 +80,56 @@ test('Quadbin geometryToCells', async t => { t.end(); }); -test('cellToBoundary works with quadbins', t => { - const result = cellToBoundary(ANY_QUADBIN); - - t.equal(result.type, 'Polygon', 'Should return a Polygon'); - t.ok(Array.isArray(result.coordinates), 'Coordinates should be an array'); - t.equal(result.coordinates.length, 1, 'Should have one boundary array'); - - t.ok(result.coordinates[0].length === 5, 'Boundary should have 5 points'); - - t.end(); -}); - -test('cellToBoundary works with Quadbins near the antimeridian', t => { - for (const quadbin of [ - BigInt(536903670), // Longitude near +180° - BigInt(536870921) // Longitude near -180° +test('Quadbin cellToBoundary', t => { + for (const {quadbin, expectedPolygon} of [ + { + quadbin: BigInt(524800), + expectedPolygon: { + type: 'Polygon', + coordinates: [ + [ + [180, 85.0511287798066], + [180, -85.05112877980659], + [-180, -85.05112877980659], + [-180, 85.0511287798066], + [180, 85.0511287798066] + ] + ] + } + }, + { + quadbin: BigInt(536903670), // Longitude near +180° + expectedPolygon: { + type: 'Polygon', + coordinates: [ + [ + [180, 85.0511287798066], + [180, -85.05112877980659], + [-180, -85.05112877980659], + [-180, 85.0511287798066], + [180, 85.0511287798066] + ] + ] + } + }, + { + quadbin: BigInt(536870921), // Longitude near -180° + expectedPolygon: { + type: 'Polygon', + coordinates: [ + [ + [180, 85.0511287798066], + [180, -85.05112877980659], + [-180, -85.05112877980659], + [-180, 85.0511287798066], + [180, 85.0511287798066] + ] + ] + } + } ]) { const result = cellToBoundary(quadbin); - - t.equal(result.type, 'Polygon', 'Should return a Polygon'); - t.ok( - result.coordinates[0][0][0] > 170 || result.coordinates[0][0][0] < -170, - 'Longitude should be near the antimeridian' - ); + t.deepEquals(result, expectedPolygon); } t.end(); diff --git a/yarn.lock b/yarn.lock index 5e75014..98fa8e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -94,6 +94,25 @@ dependencies: tilebelt "^1.0.1" +"@math.gl/core@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@math.gl/core/-/core-4.1.0.tgz#2f4a1644c6f8fb50aacae57a02f1297f933aefbd" + integrity sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA== + dependencies: + "@math.gl/types" "4.1.0" + +"@math.gl/types@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@math.gl/types/-/types-4.1.0.tgz#ce28c06bcfe07d21311e00aeb25de82fecf7f393" + integrity sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA== + +"@math.gl/web-mercator@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz#b244112b2805ba68cdecc76f3d12578d05271a1d" + integrity sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw== + dependencies: + "@math.gl/core" "4.1.0" + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"