UNPKG

protomaps-leaflet

Version:

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

524 lines (471 loc) 14.8 kB
import Point from "@mapbox/point-geometry"; import rBush from "rbush"; import { Filter } from "./painter"; import { DrawExtra, LabelSymbolizer } from "./symbolizer"; import { Bbox, JsonObject, toIndex } from "./tilecache"; import { PreparedTile, transformGeom } from "./view"; type TileInvalidationCallback = (tiles: Set<string>) => void; // the anchor should be contained within, or on the boundary of, // one of the bounding boxes. This is not enforced by library, // but is required for label deduplication. export interface Label { anchor: Point; bboxes: Bbox[]; draw: (ctx: CanvasRenderingContext2D, drawExtra?: DrawExtra) => void; deduplicationKey?: string; deduplicationDistance?: number; } export interface IndexedLabel { anchor: Point; bboxes: Bbox[]; draw: (ctx: CanvasRenderingContext2D) => void; order: number; tileKey: string; deduplicationKey?: string; deduplicationDistance?: number; } type TreeItem = Bbox & { indexedLabel: IndexedLabel }; export interface Layout { index: Index; order: number; scratch: CanvasRenderingContext2D; zoom: number; overzoom: number; } export interface LabelRule { id?: string; minzoom?: number; maxzoom?: number; dataSource?: string; dataLayer: string; symbolizer: LabelSymbolizer; filter?: Filter; visible?: boolean; sort?: (a: JsonObject, b: JsonObject) => number; } export const covering = ( displayZoom: number, tileWidth: number, bbox: Bbox, ) => { const res = 256; const f = tileWidth / res; const minx = Math.floor(bbox.minX / res); const miny = Math.floor(bbox.minY / res); const maxx = Math.floor(bbox.maxX / res); const maxy = Math.floor(bbox.maxY / res); const leveldiff = Math.log2(f); const retval = []; for (let x = minx; x <= maxx; x++) { const wrappedX = x % (1 << displayZoom); for (let y = miny; y <= maxy; y++) { retval.push({ display: toIndex({ z: displayZoom, x: wrappedX, y: y }), key: toIndex({ z: displayZoom - leveldiff, x: Math.floor(wrappedX / f), y: Math.floor(y / f), }), }); } } return retval; }; export class Index { tree: rBush<TreeItem>; current: Map<string, Set<IndexedLabel>>; dim: number; maxLabeledTiles: number; constructor(dim: number, maxLabeledTiles: number) { this.tree = new rBush(); this.current = new Map(); this.dim = dim; this.maxLabeledTiles = maxLabeledTiles; } public hasPrefix(tileKey: string): boolean { for (const key of this.current.keys()) { if (key.startsWith(tileKey)) return true; } return false; } public has(tileKey: string): boolean { return this.current.has(tileKey); } public size(): number { return this.current.size; } public keys() { return this.current.keys(); } public searchBbox(bbox: Bbox, order: number): Set<IndexedLabel> { const labels = new Set<IndexedLabel>(); for (const match of this.tree.search(bbox)) { if (match.indexedLabel.order <= order) { labels.add(match.indexedLabel); } } return labels; } public searchLabel(label: Label, order: number): Set<IndexedLabel> { const labels = new Set<IndexedLabel>(); for (const bbox of label.bboxes) { for (const match of this.tree.search(bbox)) { if (match.indexedLabel.order <= order) { labels.add(match.indexedLabel); } } } return labels; } public bboxCollides(bbox: Bbox, order: number): boolean { for (const match of this.tree.search(bbox)) { if (match.indexedLabel.order <= order) return true; } return false; } public labelCollides(label: Label, order: number): boolean { for (const bbox of label.bboxes) { for (const match of this.tree.search(bbox)) { if (match.indexedLabel.order <= order) return true; } } return false; } public deduplicationCollides(label: Label): boolean { // create a bbox around anchor to find potential matches. // this is depending on precondition: (anchor is contained within, or on boundary of, a label bbox) if (!label.deduplicationKey || !label.deduplicationDistance) return false; const dist = label.deduplicationDistance; const testBbox = { minX: label.anchor.x - dist, minY: label.anchor.y - dist, maxX: label.anchor.x + dist, maxY: label.anchor.y + dist, }; for (const collision of this.tree.search(testBbox)) { if (collision.indexedLabel.deduplicationKey === label.deduplicationKey) { if (collision.indexedLabel.anchor.dist(label.anchor) < dist) { return true; } } } return false; } public makeEntry(tileKey: string) { if (this.current.get(tileKey)) { console.log("consistency error 1"); } const newSet = new Set<IndexedLabel>(); this.current.set(tileKey, newSet); } // can put in multiple due to antimeridian wrapping public insert(label: Label, order: number, tileKey: string): void { const indexedLabel = { anchor: label.anchor, bboxes: label.bboxes, draw: label.draw, order: order, tileKey: tileKey, deduplicationKey: label.deduplicationKey, deduplicationDistance: label.deduplicationDistance, }; let entry = this.current.get(tileKey); if (!entry) { const newSet = new Set<IndexedLabel>(); this.current.set(tileKey, newSet); entry = newSet; } entry.add(indexedLabel); let wrapsLeft = false; let wrapsRight = false; for (const bbox of label.bboxes) { this.tree.insert({ minX: bbox.minX, minY: bbox.minY, maxX: bbox.maxX, maxY: bbox.maxY, indexedLabel: indexedLabel, }); if (bbox.minX < 0) wrapsLeft = true; if (bbox.maxX > this.dim) wrapsRight = true; } if (wrapsLeft || wrapsRight) { const shift = wrapsLeft ? this.dim : -this.dim; const newBboxes = []; for (const bbox of label.bboxes) { newBboxes.push({ minX: bbox.minX + shift, minY: bbox.minY, maxX: bbox.maxX + shift, maxY: bbox.maxY, }); } const duplicateLabel = { anchor: new Point(label.anchor.x + shift, label.anchor.y), bboxes: newBboxes, draw: label.draw, order: order, tileKey: tileKey, }; const entry = this.current.get(tileKey); if (entry) entry.add(duplicateLabel); for (const bbox of newBboxes) { this.tree.insert({ minX: bbox.minX, minY: bbox.minY, maxX: bbox.maxX, maxY: bbox.maxY, indexedLabel: duplicateLabel, }); } } } public pruneOrNoop(keyAdded: string) { const added = keyAdded.split(":"); let maxKey = undefined; let maxDist = 0; let keysForDs = 0; for (const existingKey of this.current.keys()) { const existing = existingKey.split(":"); if (existing[3] === added[3]) { keysForDs++; const dist = Math.sqrt( (+existing[0] - +added[0]) ** 2 + (+existing[1] - +added[1]) ** 2, ); if (dist > maxDist) { maxDist = dist; maxKey = existingKey; } } if (maxKey && keysForDs > this.maxLabeledTiles) { this.pruneKey(maxKey); } } } public pruneKey(keyToRemove: string): void { const indexedLabels = this.current.get(keyToRemove); if (!indexedLabels) return; // TODO: not that clean... const entriesToDelete = []; for (const entry of this.tree.all()) { if (indexedLabels.has(entry.indexedLabel)) { entriesToDelete.push(entry); } } for (const entry of entriesToDelete) { this.tree.remove(entry); } this.current.delete(keyToRemove); } // NOTE: technically this is incorrect // with antimeridian wrapping, since we should also remove // the duplicate label; but i am having a hard time // imagining where this will happen in practical usage public removeLabel(labelToRemove: IndexedLabel): void { const entriesToDelete = []; for (const entry of this.tree.all()) { if (labelToRemove === entry.indexedLabel) { entriesToDelete.push(entry); } } for (const entry of entriesToDelete) { this.tree.remove(entry); } const c = this.current.get(labelToRemove.tileKey); if (c) c.delete(labelToRemove); } } export class Labeler { index: Index; z: number; scratch: CanvasRenderingContext2D; labelRules: LabelRule[]; callback?: TileInvalidationCallback; constructor( z: number, scratch: CanvasRenderingContext2D, labelRules: LabelRule[], maxLabeledTiles: number, callback?: TileInvalidationCallback, ) { this.index = new Index((256 * 1) << z, maxLabeledTiles); this.z = z; this.scratch = scratch; this.labelRules = labelRules; this.callback = callback; } private layout(preparedTilemap: Map<string, PreparedTile[]>): number { const start = performance.now(); const keysAdding = new Set<string>(); // if it already exists... short circuit for (const [k, preparedTiles] of preparedTilemap) { for (const preparedTile of preparedTiles) { const key = `${toIndex(preparedTile.dataTile)}:${k}`; if (!this.index.has(key)) { this.index.makeEntry(key); keysAdding.add(key); } } } const tilesInvalidated = new Set<string>(); for (const [order, rule] of this.labelRules.entries()) { if (rule.visible === false) continue; if (rule.minzoom && this.z < rule.minzoom) continue; if (rule.maxzoom && this.z > rule.maxzoom) continue; const dsName = rule.dataSource || ""; const preparedTiles = preparedTilemap.get(dsName); if (!preparedTiles) continue; for (const preparedTile of preparedTiles) { const key = `${toIndex(preparedTile.dataTile)}:${dsName}`; if (!keysAdding.has(key)) continue; const layer = preparedTile.data.get(rule.dataLayer); if (layer === undefined) continue; const feats = layer; if (rule.sort) feats.sort((a, b) => { if (rule.sort) { return rule.sort(a.props, b.props); } return 0; }); const layout = { index: this.index, zoom: this.z, scratch: this.scratch, order: order, overzoom: this.z - preparedTile.dataTile.z, }; for (const feature of feats) { if (rule.filter && !rule.filter(this.z, feature)) continue; const transformed = transformGeom( feature.geom, preparedTile.scale, preparedTile.origin, ); const labels = rule.symbolizer.place(layout, transformed, feature); if (!labels) continue; for (const label of labels) { let labelAdded = false; if ( label.deduplicationKey && this.index.deduplicationCollides(label) ) { continue; } // does the label collide with anything? if (this.index.labelCollides(label, Infinity)) { if (!this.index.labelCollides(label, order)) { const conflicts = this.index.searchLabel(label, Infinity); for (const conflict of conflicts) { this.index.removeLabel(conflict); for (const bbox of conflict.bboxes) { this.findInvalidatedTiles( tilesInvalidated, preparedTile.dim, bbox, key, ); } } this.index.insert(label, order, key); labelAdded = true; } // label not added. } else { this.index.insert(label, order, key); labelAdded = true; } if (labelAdded) { for (const bbox of label.bboxes) { if ( bbox.maxX > preparedTile.origin.x + preparedTile.dim || bbox.minX < preparedTile.origin.x || bbox.minY < preparedTile.origin.y || bbox.maxY > preparedTile.origin.y + preparedTile.dim ) { this.findInvalidatedTiles( tilesInvalidated, preparedTile.dim, bbox, key, ); } } } } } } } for (const key of keysAdding) { this.index.pruneOrNoop(key); } if (tilesInvalidated.size > 0 && this.callback) { this.callback(tilesInvalidated); } return performance.now() - start; } private findInvalidatedTiles( tilesInvalidated: Set<string>, dim: number, bbox: Bbox, key: string, ) { const touched = covering(this.z, dim, bbox); for (const s of touched) { if (s.key !== key && this.index.hasPrefix(s.key)) { tilesInvalidated.add(s.display); } } } public add(preparedTilemap: Map<string, PreparedTile[]>): number { let allAdded = true; for (const [k, preparedTiles] of preparedTilemap) { for (const preparedTile of preparedTiles) { if (!this.index.has(`${toIndex(preparedTile.dataTile)}:${k}`)) allAdded = false; } } if (allAdded) { return 0; } const timing = this.layout(preparedTilemap); return timing; } } export class Labelers { labelers: Map<number, Labeler>; scratch: CanvasRenderingContext2D; labelRules: LabelRule[]; maxLabeledTiles: number; callback: TileInvalidationCallback; constructor( scratch: CanvasRenderingContext2D, labelRules: LabelRule[], maxLabeledTiles: number, callback: TileInvalidationCallback, ) { this.labelers = new Map<number, Labeler>(); this.scratch = scratch; this.labelRules = labelRules; this.maxLabeledTiles = maxLabeledTiles; this.callback = callback; } public add(z: number, preparedTilemap: Map<string, PreparedTile[]>): number { let labeler = this.labelers.get(z); if (labeler) { return labeler.add(preparedTilemap); } labeler = new Labeler( z, this.scratch, this.labelRules, this.maxLabeledTiles, this.callback, ); this.labelers.set(z, labeler); return labeler.add(preparedTilemap); } public getIndex(z: number) { const labeler = this.labelers.get(z); if (labeler) return labeler.index; // TODO cleanup } }