protomaps-leaflet
Version:
Vector tile rendering and labeling for [Leaflet](https://github.com/Leaflet/Leaflet).
390 lines (389 loc) • 14.4 kB
JavaScript
import Point from "@mapbox/point-geometry";
import rBush from "rbush";
import { toIndex } from "./tilecache";
import { transformGeom } from "./view";
export const covering = (displayZoom, tileWidth, 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 {
constructor(dim, maxLabeledTiles) {
this.tree = new rBush();
this.current = new Map();
this.dim = dim;
this.maxLabeledTiles = maxLabeledTiles;
}
hasPrefix(tileKey) {
for (const key of this.current.keys()) {
if (key.startsWith(tileKey))
return true;
}
return false;
}
has(tileKey) {
return this.current.has(tileKey);
}
size() {
return this.current.size;
}
keys() {
return this.current.keys();
}
searchBbox(bbox, order) {
const labels = new Set();
for (const match of this.tree.search(bbox)) {
if (match.indexedLabel.order <= order) {
labels.add(match.indexedLabel);
}
}
return labels;
}
searchLabel(label, order) {
const labels = new Set();
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;
}
bboxCollides(bbox, order) {
for (const match of this.tree.search(bbox)) {
if (match.indexedLabel.order <= order)
return true;
}
return false;
}
labelCollides(label, order) {
for (const bbox of label.bboxes) {
for (const match of this.tree.search(bbox)) {
if (match.indexedLabel.order <= order)
return true;
}
}
return false;
}
deduplicationCollides(label) {
// 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;
}
makeEntry(tileKey) {
if (this.current.get(tileKey)) {
console.log("consistency error 1");
}
const newSet = new Set();
this.current.set(tileKey, newSet);
}
// can put in multiple due to antimeridian wrapping
insert(label, order, tileKey) {
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();
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,
});
}
}
}
pruneOrNoop(keyAdded) {
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(Math.pow((+existing[0] - +added[0]), 2) + Math.pow((+existing[1] - +added[1]), 2));
if (dist > maxDist) {
maxDist = dist;
maxKey = existingKey;
}
}
if (maxKey && keysForDs > this.maxLabeledTiles) {
this.pruneKey(maxKey);
}
}
}
pruneKey(keyToRemove) {
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
removeLabel(labelToRemove) {
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 {
constructor(z, scratch, labelRules, maxLabeledTiles, callback) {
this.index = new Index((256 * 1) << z, maxLabeledTiles);
this.z = z;
this.scratch = scratch;
this.labelRules = labelRules;
this.callback = callback;
}
layout(preparedTilemap) {
const start = performance.now();
const keysAdding = new Set();
// 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();
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;
}
findInvalidatedTiles(tilesInvalidated, dim, bbox, key) {
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);
}
}
}
add(preparedTilemap) {
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 {
constructor(scratch, labelRules, maxLabeledTiles, callback) {
this.labelers = new Map();
this.scratch = scratch;
this.labelRules = labelRules;
this.maxLabeledTiles = maxLabeledTiles;
this.callback = callback;
}
add(z, preparedTilemap) {
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);
}
getIndex(z) {
const labeler = this.labelers.get(z);
if (labeler)
return labeler.index; // TODO cleanup
}
}