LeafletTopology = {
function coordsEqual(a, b) {
if (a === b) return true;
return (a.length == b.length && a[0] === b[0] && a[1] === b[1]);
}
function arcsEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
return (a.length == b.length && coordsEqual(a[0], b[0]) && coordsEqual(a[1], b[1]));
}
function getBasePxArea(crs, latLngBounds) {
const pointNw = crs.latLngToPoint(latLngBounds.getNorthWest(), 1);
const pointSe = crs.latLngToPoint(latLngBounds.getSouthEast(), 1);
const bounds = L.bounds(pointNw, pointSe);
const size = bounds.getSize();
return size.x * size.x + size.y * size.y;
}
class LeafletTopology {
constructor(topology, map) {
this._map = map;
this._basePxArea = undefined;
this._ringPxAreas = new Map();
this._arcLayerIds = new Map();
this._loadTopology(topology);
const fnUpdateArcs = this._updateArcs.bind(this);
const startUpdating = (function() {
if (this._layerUpdateInterval) return;
this._layerUpdateInterval = setInterval(fnUpdateArcs, 100);
}).bind(this);
const stopUpdating = (function() {
clearInterval(this._layerUpdateInterval);
this._layerUpdateInterval = undefined;
this._updateArcs();
}).bind(this);
map.on("movestart", startUpdating);
map.on("moveend", stopUpdating);
map.once("unload", function() {
this._map.off("movestart", startUpdating);
this._map.off("moveend", stopUpdating);
this._map = undefined;
stopUpdating();
}, this);
}
createLayer(geometry, options) {
if (!geometry) return null;
if (geometry.type == "GeometryCollection") {
const layers = geometry.geometries.map(g => this.createLayer(g, options)).filter(g => g);
return new L.FeatureGroup(layers);
}
let layer;
switch (geometry.type) {
case 'LineString':
case 'MultiLineString':
layer = new PolylineArc(this, geometry, options);
break;
case 'Polygon':
case 'MultiPolygon':
layer = new PolygonArc(this, geometry, options);
break;
default:
throw new Error('Invalid/unsupported GeoJSON object.');
}
const stamp = L.Util.stamp(layer);
const arcIds = geometry.arcs.flat().flat().map(i => (i < 0) ? ~i : i);
arcIds.forEach(arcId => this._arcLayerIds.get(arcId).add(stamp));
return layer;
}
_updateArcs() {
const map = this._map;
if (!map) return;
const bounds = map.getBounds();
const visiblePxArea = getBasePxArea(this._getCrs(), bounds);
const pctVisible = visiblePxArea / this._basePxArea;
const nw = bounds.getNorthWest();
const se = bounds.getSouthEast();
const bbox = Bbox.fromPoints([[nw.lng, nw.lat], [se.lng, se.lat]]);
const visibleArcIds = new Set(this._index.search(...bbox));
// TODO: options.smoothFactor
const smoothFactor = 1;
const tolerance = Math.min(this._lowResFloorPx, smoothFactor * pctVisible);
const dirtyArcIds = new Set();
this._lowResArcs.forEach((arc, i) => {
if (visibleArcIds.has(i)) {
arc = this._sourceArcs[i].filter(c => c[2] >= tolerance).map(c => [c[0], c[1]]);
}
if (!arcsEqual(arc, this.arcs[i])) dirtyArcIds.add(i);
this.arcs[i] = arc;
});
const stampsToUpdate = new Set();
for (let arcId of dirtyArcIds) {
for (let stamp of this._arcLayerIds.get(arcId)) {
stampsToUpdate.add(stamp);
}
}
console.log(`Redrawing ${stampsToUpdate.size} objects @ tolerance ${smoothFactor / pctVisible}; ${this.arcs.flat().length}/${this._sourceArcs.flat().length} points in topology.`);
this._map.eachLayer(l => {
if (l.markDirty && stampsToUpdate.has(L.Util.stamp(l))) {
l.markDirty();
}
});
}
_getCrs() {
return this._map?.options.crs;
}
_loadTopology(topology) {
this.type = topology.type;
this.objects = topology.objects;
// Unquantize and create _sourceArcs
const transformCoord = topojson.transform(topology.transform);
const sourceArcs = this._sourceArcs = topology.arcs.map(arc => {
return arc.map(c => transformCoord(c));
});
// Build index
const index = this._index = new Flatbush(sourceArcs.length);
sourceArcs.forEach(arc => index.add(...Bbox.fromPoints(arc)));
index.finish();
// Presimplify the topology
this.presimplifyTopology();
// Build _lowResArcs and arcs
this._lowResArcs = sourceArcs.map(a => a.filter(c => c[2] >= this._lowResFloorPx).map(c => [c[0], c[1]]));
this.arcs = [...this._lowResArcs];
// Initialize this._arcLayerIds
for (let arcId = 0; arcId < sourceArcs.length; arcId++) {
if (this._arcLayerIds.has(arcId)) continue;
this._arcLayerIds.set(arcId, new Set());
}
console.log(`Topology loaded. ${sourceArcs.length} arcs, ${sourceArcs.flat().length} points.`);
}
presimplifyTopology() {
const sourceArcs = this._sourceArcs;
// Presimplify a temporary copy of this topology, with lat/lng converted to screen coords at zoom level 1
const crs = this._getCrs();
const presimplified = topojson.presimplify({
type: "Topology",
arcs: sourceArcs.map(arc => {
return arc.map(([lng,lat,value]) => {
const { x, y } = crs.latLngToPoint(L.latLng(lat, lng), 1);
return [x, y];
});
})
});
// Calculate this._basePxArea, the area (in pixels) of the topology at zoom level 1
const [n, e, s, w] = Bbox.fromPoints(presimplified.arcs.flat());
this._basePxArea = getBasePxArea(crs, L.latLngBounds(L.latLng(n, w), L.latLng(s, e)));
// Update sourceArcs to include the new presimplification scores, expressed as a fraction of this._basePxArea
sourceArcs.forEach((arc, i) => {
arc.forEach((coord, j) => {
if (coord[2] == Infinity) return;
coord[2] = presimplified.arcs[i][j][2];
});
});
const allScores = sourceArcs.flat().map(c => c[2]).filter(c => c > 0 && isFinite(c));
allScores.sort((a,b) => a-b);
const lowResFloorIndex = Math.floor(allScores.length - simple.arcs.length / 3);
this._lowResFloorPx = allScores[lowResFloorIndex];
}
}
return LeafletTopology;
}