@deck.gl/carto
Version:
CARTO official integration with Deck.gl. Build geospatial applications using CARTO and Deck.gl.
177 lines (157 loc) • 6.01 kB
text/typescript
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import {_Tileset2D as Tileset2D, GeoBoundingBox} from '@deck.gl/geo-layers';
import {
polygonToCells,
latLngToCell,
getResolution,
cellToBoundary,
cellToParent,
gridDisk,
edgeLength,
UNITS,
originToDirectedEdges
} from 'h3-js';
export type H3TileIndex = {i: string};
const MAX_LATITUDE = 85.051128;
/**
* `polygonToCells()` fills based on hexagon center, this function will
* pad the bounds such that all cells that overlap the bounds will be included.
*
* @param bbox - The bounding box to pad.
* @param resolution - The resolution of the hexagons.
* @param scale - The scale of the buffer. 1 is to pad by the max edge length of the tile cell.
* @returns The padded bounding box.
*/
function padBoundingBox(
{west, north, east, south}: GeoBoundingBox,
resolution: number,
scale: number = 1.0
): GeoBoundingBox {
const corners = [
[north, east],
[south, east],
[south, west],
[north, west]
];
const cornerCells = corners.map(c => latLngToCell(c[0], c[1], resolution));
const cornerEdgeLengths = cornerCells.map(
c => (Math.max(...originToDirectedEdges(c).map(e => edgeLength(e, UNITS.rads))) * 180) / Math.PI
);
const bufferLat = Math.max(...cornerEdgeLengths) * scale;
const bufferLon = Math.min(180, bufferLat / Math.cos((((north + south) / 2) * Math.PI) / 180));
return {
north: Math.min(north + bufferLat, MAX_LATITUDE),
east: east + bufferLon,
south: Math.max(south - bufferLat, -MAX_LATITUDE),
west: west - bufferLon
};
}
function getHexagonsInBoundingBox(
{west, north, east, south}: GeoBoundingBox,
resolution: number
): string[] {
const longitudeSpan = Math.abs(east - west);
if (longitudeSpan > 180) {
// This is a known issue in h3-js: polygonToCells does not work correctly
// when longitude span is larger than 180 degrees.
const nSegments = Math.ceil(longitudeSpan / 180);
let h3Indices: string[] = [];
for (let s = 0; s < nSegments; s++) {
const segmentWest = west + s * 180;
const segmentEast = Math.min(segmentWest + 179.9999999, east);
h3Indices = h3Indices.concat(
getHexagonsInBoundingBox({west: segmentWest, north, east: segmentEast, south}, resolution)
);
}
return [...new Set(h3Indices)];
}
const polygon = [
[north, east],
[south, east],
[south, west],
[north, west],
[north, east]
];
return polygonToCells(polygon, resolution);
}
function tileToBoundingBox(index: string): GeoBoundingBox {
const coordinates = cellToBoundary(index);
const latitudes = coordinates.map(c => c[0]);
const longitudes = coordinates.map(c => c[1]);
const west = Math.min(...longitudes);
const south = Math.min(...latitudes);
const east = Math.max(...longitudes);
const north = Math.max(...latitudes);
const bbox = {west, south, east, north};
// H3 child cells extend beyond their parent's boundary forming a "snowflake"
// fractal pattern. The required buffer is approximately 10% of the
// edge length of the tile cell, add a bit more to be safe.
return padBoundingBox(bbox, getResolution(index), 0.12);
}
// Resolution conversion function. Takes a WebMercatorViewport and returns
// a H3 resolution such that the screen space size of the hexagons is
// similar
// Relative scale factor (0 = no biasing, 2 = a few hexagons cover view)
const BIAS = 2;
export function getHexagonResolution(
viewport: {zoom: number; latitude: number},
tileSize: number
): number {
// Difference in given tile size compared to deck's internal 512px tile size,
// expressed as an offset to the viewport zoom.
const zoomOffset = Math.log2(tileSize / 512);
const hexagonScaleFactor = (2 / 3) * (viewport.zoom - zoomOffset);
const latitudeScaleFactor = Math.log(1 / Math.cos((Math.PI * viewport.latitude) / 180));
// Clip and bias
return Math.max(0, Math.floor(hexagonScaleFactor + latitudeScaleFactor - BIAS));
}
export default class H3Tileset2D extends Tileset2D {
/**
* Returns all tile indices in the current viewport. If the current zoom level is smaller
* than minZoom, return an empty array. If the current zoom level is greater than maxZoom,
* return tiles that are on maxZoom.
*/
// @ts-expect-error Tileset2D should be generic over TileIndex
getTileIndices({viewport, minZoom, maxZoom}): H3TileIndex[] {
if (viewport.latitude === undefined) return [];
const [west, south, east, north] = viewport.getBounds();
const {tileSize} = this.opts;
let z = getHexagonResolution(viewport, tileSize);
let indices: string[];
if (typeof minZoom === 'number' && Number.isFinite(minZoom) && z < minZoom) {
// TODO support `extent` prop
return [];
}
if (typeof maxZoom === 'number' && Number.isFinite(maxZoom) && z > maxZoom) {
z = maxZoom;
// Once we are at max zoom, getHexagonsInBoundingBox doesn't work, simply
// get a ring centered on the hexagon in the viewport center
const center = latLngToCell(viewport.latitude, viewport.longitude, maxZoom);
indices = gridDisk(center, 1);
} else {
const paddedBounds = padBoundingBox({west, north, east, south}, z);
indices = getHexagonsInBoundingBox(paddedBounds, z);
}
return indices.map(i => ({i}));
}
// @ts-expect-error Tileset2D should be generic over TileIndex
getTileId({i}: H3TileIndex): string {
return i;
}
// @ts-expect-error Tileset2D should be generic over TileIndex
getTileMetadata({i}: H3TileIndex) {
return {bbox: tileToBoundingBox(i)};
}
// @ts-expect-error Tileset2D should be generic over TileIndex
getTileZoom({i}: H3TileIndex): number {
return getResolution(i);
}
// @ts-expect-error Tileset2D should be generic over TileIndex
getParentIndex(index: H3TileIndex): H3TileIndex {
const resolution = getResolution(index.i);
const i = cellToParent(index.i, resolution - 1);
return {i};
}
}