UNPKG

protomaps-leaflet

Version:

Vector tile rendering and labeling for [Leaflet](https://github.com/Leaflet/Leaflet).

435 lines (396 loc) 11.7 kB
import Point from "@mapbox/point-geometry"; import { VectorTile } from "@mapbox/vector-tile"; import Protobuf from "pbf"; import { PMTiles } from "pmtiles"; export type JsonValue = | boolean | number | string | null | JsonArray | JsonObject; export interface JsonObject { [key: string]: JsonValue; } export interface JsonArray extends Array<JsonValue> {} export enum GeomType { Point = 1, Line = 2, Polygon = 3, } export interface Bbox { minX: number; minY: number; maxX: number; maxY: number; } export interface Feature { readonly props: JsonObject; readonly bbox: Bbox; readonly geomType: GeomType; readonly geom: Point[][]; readonly numVertices: number; } export interface Zxy { readonly z: number; readonly x: number; readonly y: number; } export function toIndex(c: Zxy): string { return `${c.x}:${c.y}:${c.z}`; } export interface TileSource { get(c: Zxy, tileSize: number): Promise<Map<string, Feature[]>>; } interface ZoomAbort { z: number; controller: AbortController; } // reimplement loadGeometry with a scalefactor // so the general tile rendering case does not need rescaling. const loadGeomAndBbox = (pbf: Protobuf, geometry: number, scale: number) => { pbf.pos = geometry; const end = pbf.readVarint() + pbf.pos; let cmd = 1; let length = 0; let x = 0; let y = 0; let x1 = Infinity; let x2 = -Infinity; let y1 = Infinity; let y2 = -Infinity; const lines: Point[][] = []; let line: Point[] = []; while (pbf.pos < end) { if (length <= 0) { const cmdLen = pbf.readVarint(); cmd = cmdLen & 0x7; length = cmdLen >> 3; } length--; if (cmd === 1 || cmd === 2) { x += pbf.readSVarint() * scale; y += pbf.readSVarint() * scale; if (x < x1) x1 = x; if (x > x2) x2 = x; if (y < y1) y1 = y; if (y > y2) y2 = y; if (cmd === 1) { if (line.length > 0) lines.push(line); line = []; } line.push(new Point(x, y)); } else if (cmd === 7) { if (line) line.push(line[0].clone()); } else throw new Error(`unknown command ${cmd}`); } if (line) lines.push(line); return { geom: lines, bbox: { minX: x1, minY: y1, maxX: x2, maxY: y2 } }; }; function parseTile( buffer: ArrayBuffer, tileSize: number, ): Map<string, Feature[]> { const v = new VectorTile(new Protobuf(buffer)); const result = new Map<string, Feature[]>(); for (const [key, value] of Object.entries(v.layers)) { const features = []; // biome-ignore lint: need to use private fields of vector-tile const layer = value as any; for (let i = 0; i < layer.length; i++) { const loaded = loadGeomAndBbox( layer.feature(i)._pbf, layer.feature(i)._geometry, tileSize / layer.extent, ); let numVertices = 0; for (const part of loaded.geom) numVertices += part.length; features.push({ id: layer.feature(i).id, geomType: layer.feature(i).type, geom: loaded.geom, numVertices: numVertices, bbox: loaded.bbox, props: layer.feature(i).properties, }); } result.set(key, features); } return result; } export class PmtilesSource implements TileSource { p: PMTiles; zoomaborts: ZoomAbort[]; shouldCancelZooms: boolean; constructor(url: string | PMTiles, shouldCancelZooms: boolean) { if (typeof url === "string") { this.p = new PMTiles(url); } else { this.p = url; } this.zoomaborts = []; this.shouldCancelZooms = shouldCancelZooms; } public async get(c: Zxy, tileSize: number): Promise<Map<string, Feature[]>> { if (this.shouldCancelZooms) { this.zoomaborts = this.zoomaborts.filter((za) => { if (za.z !== c.z) { za.controller.abort(); return false; } return true; }); } const controller = new AbortController(); this.zoomaborts.push({ z: c.z, controller: controller }); const signal = controller.signal; const result = await this.p.getZxy(c.z, c.x, c.y, signal); if (result) { return parseTile(result.data, tileSize); } return new Map<string, Feature[]>(); } } export class ZxySource implements TileSource { url: string; zoomaborts: ZoomAbort[]; shouldCancelZooms: boolean; constructor(url: string, shouldCancelZooms: boolean) { this.url = url; this.zoomaborts = []; this.shouldCancelZooms = shouldCancelZooms; } public async get(c: Zxy, tileSize: number): Promise<Map<string, Feature[]>> { if (this.shouldCancelZooms) { this.zoomaborts = this.zoomaborts.filter((za) => { if (za.z !== c.z) { za.controller.abort(); return false; } return true; }); } const url = this.url .replace("{z}", c.z.toString()) .replace("{x}", c.x.toString()) .replace("{y}", c.y.toString()); const controller = new AbortController(); this.zoomaborts.push({ z: c.z, controller: controller }); const signal = controller.signal; return new Promise((resolve, reject) => { fetch(url, { signal: signal }) .then((resp) => { return resp.arrayBuffer(); }) .then((buffer) => { const result = parseTile(buffer, tileSize); resolve(result); }) .catch((e) => { reject(e); }); }); } } interface CacheEntry { used: number; data: Map<string, Feature[]>; } interface PromiseOptions { resolve: (result: Map<string, Feature[]>) => void; reject: (e: Error) => void; } export interface PickedFeature { feature: Feature; layerName: string; } const R = 6378137; const MAX_LATITUDE = 85.0511287798; const MAXCOORD = R * Math.PI; const project = (latlng: number[]) => { const d = Math.PI / 180; const constrainedLat = Math.max( Math.min(MAX_LATITUDE, latlng[0]), -MAX_LATITUDE, ); const sin = Math.sin(constrainedLat * d); return new Point( R * latlng[1] * d, (R * Math.log((1 + sin) / (1 - sin))) / 2, ); }; function sqr(x: number) { return x * x; } function dist2(v: Point, w: Point) { return sqr(v.x - w.x) + sqr(v.y - w.y); } function distToSegmentSquared(p: Point, v: Point, w: Point) { const l2 = dist2(v, w); if (l2 === 0) return dist2(p, v); let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; t = Math.max(0, Math.min(1, t)); return dist2(p, new Point(v.x + t * (w.x - v.x), v.y + t * (w.y - v.y))); } export function isInRing(point: Point, ring: Point[]): boolean { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const xi = ring[i].x; const yi = ring[i].y; const xj = ring[j].x; const yj = ring[j].y; const intersect = yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; } export function isCcw(ring: Point[]): boolean { let area = 0; for (let i = 0; i < ring.length; i++) { const j = (i + 1) % ring.length; area += ring[i].x * ring[j].y; area -= ring[j].x * ring[i].y; } return area < 0; } export function pointInPolygon(point: Point, geom: Point[][]): boolean { let isInCurrentExterior = false; for (const ring of geom) { if (isCcw(ring)) { // it is an interior ring if (isInRing(point, ring)) isInCurrentExterior = false; } else { // it is an exterior ring if (isInCurrentExterior) return true; if (isInRing(point, ring)) isInCurrentExterior = true; } } return isInCurrentExterior; } export function pointMinDistToPoints(point: Point, geom: Point[][]): number { let min = Infinity; for (const l of geom) { const dist = Math.sqrt(dist2(point, l[0])); if (dist < min) min = dist; } return min; } export function pointMinDistToLines(point: Point, geom: Point[][]): number { let min = Infinity; for (const l of geom) { for (let i = 0; i < l.length - 1; i++) { const dist = Math.sqrt(distToSegmentSquared(point, l[i], l[i + 1])); if (dist < min) min = dist; } } return min; } export class TileCache { source: TileSource; cache: Map<string, CacheEntry>; inflight: Map<string, PromiseOptions[]>; tileSize: number; constructor(source: TileSource, tileSize: number) { this.source = source; this.cache = new Map<string, CacheEntry>(); this.inflight = new Map<string, PromiseOptions[]>(); this.tileSize = tileSize; } public async get(c: Zxy): Promise<Map<string, Feature[]>> { const idx = toIndex(c); return new Promise((resolve, reject) => { const entry = this.cache.get(idx); if (entry) { entry.used = performance.now(); resolve(entry.data); } else { const ifentry = this.inflight.get(idx); if (ifentry) { ifentry.push({ resolve: resolve, reject: reject }); } else { this.inflight.set(idx, []); this.source .get(c, this.tileSize) .then((tile) => { this.cache.set(idx, { used: performance.now(), data: tile }); const ifentry2 = this.inflight.get(idx); if (ifentry2) { for (const f of ifentry2) { f.resolve(tile); } } this.inflight.delete(idx); resolve(tile); if (this.cache.size >= 64) { let minUsed = +Infinity; let minKey = undefined; this.cache.forEach((value, key) => { if (value.used < minUsed) { minUsed = value.used; minKey = key; } }); if (minKey) this.cache.delete(minKey); } }) .catch((e) => { const ifentry2 = this.inflight.get(idx); if (ifentry2) { for (const f of ifentry2) { f.reject(e); } } this.inflight.delete(idx); reject(e); }); } } }); } public queryFeatures( lng: number, lat: number, zoom: number, brushSize: number, ): PickedFeature[] { const projected = project([lat, lng]); const normalized = new Point( (projected.x + MAXCOORD) / (MAXCOORD * 2), 1 - (projected.y + MAXCOORD) / (MAXCOORD * 2), ); if (normalized.x > 1) normalized.x = normalized.x - Math.floor(normalized.x); const onZoom = normalized.mult(1 << zoom); const tileX = Math.floor(onZoom.x); const tileY = Math.floor(onZoom.y); const idx = toIndex({ z: zoom, x: tileX, y: tileY }); const retval: PickedFeature[] = []; const entry = this.cache.get(idx); if (entry) { const center = new Point( (onZoom.x - tileX) * this.tileSize, (onZoom.y - tileY) * this.tileSize, ); for (const [layerName, layerArr] of entry.data.entries()) { for (const feature of layerArr) { if (feature.geomType === GeomType.Point) { if (pointMinDistToPoints(center, feature.geom) < brushSize) { retval.push({ feature, layerName: layerName }); } } else if (feature.geomType === GeomType.Line) { if (pointMinDistToLines(center, feature.geom) < brushSize) { retval.push({ feature, layerName: layerName }); } } else { if (pointInPolygon(center, feature.geom)) { retval.push({ feature, layerName: layerName }); } } } } } return retval; } }