Speed up address and road diffs from hours to seconds, gzip GeoJSON downloads, and render map points on canvas with viewport-only OSM/county layers
This commit is contained in:
+139
-43
@@ -123,6 +123,121 @@ function paneRenderer(pane) {
|
||||
return paneRenderers[pane];
|
||||
}
|
||||
|
||||
// A GeoJSON layer that only creates and draws the features in (or near) the
|
||||
// current view, and nothing below `minZoom`. County and OSM files hold
|
||||
// hundreds of thousands of features, far more than a browser can draw and
|
||||
// hit-test on every pan. Layers are created on first view and then cached,
|
||||
// so a selection keeps pointing at the same layer object.
|
||||
const ViewportGeoJSON = L.GeoJSON.extend({
|
||||
initialize: function(geojson, options) {
|
||||
L.GeoJSON.prototype.initialize.call(this, null, options);
|
||||
const filter = this.options.filter;
|
||||
this._features = (geojson.features || []).filter(f => f && f.geometry && (!filter || filter(f)));
|
||||
this._cache = new Map(); // feature -> layer
|
||||
this._shown = new Set(); // features currently added
|
||||
this._buildIndex();
|
||||
},
|
||||
|
||||
_cellSize: 0.02, // degrees (~2 km)
|
||||
|
||||
_buildIndex: function() {
|
||||
this._grid = new Map();
|
||||
const size = this._cellSize;
|
||||
this._features.forEach(f => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const visit = c => {
|
||||
if (typeof c[0] === 'number') {
|
||||
if (c[0] < minX) minX = c[0]; if (c[0] > maxX) maxX = c[0];
|
||||
if (c[1] < minY) minY = c[1]; if (c[1] > maxY) maxY = c[1];
|
||||
} else c.forEach(visit);
|
||||
};
|
||||
visit(f.geometry.coordinates);
|
||||
if (minX === Infinity) return;
|
||||
for (let x = Math.floor(minX / size); x <= Math.floor(maxX / size); x++) {
|
||||
for (let y = Math.floor(minY / size); y <= Math.floor(maxY / size); y++) {
|
||||
const key = x + ',' + y;
|
||||
let cell = this._grid.get(key);
|
||||
if (!cell) this._grid.set(key, cell = []);
|
||||
cell.push(f);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onAdd: function(map) {
|
||||
L.GeoJSON.prototype.onAdd.call(this, map);
|
||||
map.on('moveend', this._update, this);
|
||||
this._update();
|
||||
},
|
||||
|
||||
onRemove: function(map) {
|
||||
map.off('moveend', this._update, this);
|
||||
this._setHint(false);
|
||||
L.GeoJSON.prototype.onRemove.call(this, map);
|
||||
},
|
||||
|
||||
_setHint: function(show) {
|
||||
const item = document.querySelector(`.layer-item[data-layer="${this.options.layerName}"]`);
|
||||
if (!item) return;
|
||||
let hint = item.querySelector('.zoom-hint');
|
||||
if (!hint) {
|
||||
hint = document.createElement('small');
|
||||
hint.className = 'zoom-hint';
|
||||
hint.style.cssText = 'margin-left:4px;color:#888;';
|
||||
item.appendChild(hint);
|
||||
}
|
||||
hint.textContent = show ? '(zoom in to show)' : '';
|
||||
},
|
||||
|
||||
_layerFor: function(feature) {
|
||||
let layer = this._cache.get(feature);
|
||||
if (layer) {
|
||||
// Pick up style changes made while it was out of view (e.g. imported),
|
||||
// but leave a selected layer's highlight alone.
|
||||
if (!selectedLayers.some(s => s.layer === layer)) this.resetStyle(layer);
|
||||
return layer;
|
||||
}
|
||||
// Same steps as L.GeoJSON.addData
|
||||
layer = L.GeoJSON.geometryToLayer(feature, this.options);
|
||||
if (!layer) return null;
|
||||
layer.feature = L.GeoJSON.asFeature(feature);
|
||||
layer.defaultOptions = layer.options;
|
||||
this.resetStyle(layer);
|
||||
if (this.options.onEachFeature) this.options.onEachFeature(feature, layer);
|
||||
this._cache.set(feature, layer);
|
||||
return layer;
|
||||
},
|
||||
|
||||
_update: function() {
|
||||
if (!this._map) return;
|
||||
const tooFar = this._map.getZoom() < this.options.minZoom;
|
||||
this._setHint(tooFar);
|
||||
const want = new Set();
|
||||
if (!tooFar) {
|
||||
const b = this._map.getBounds().pad(0.25), size = this._cellSize;
|
||||
for (let x = Math.floor(b.getWest() / size); x <= Math.floor(b.getEast() / size); x++) {
|
||||
for (let y = Math.floor(b.getSouth() / size); y <= Math.floor(b.getNorth() / size); y++) {
|
||||
(this._grid.get(x + ',' + y) || []).forEach(f => want.add(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
this._shown.forEach(f => {
|
||||
if (!want.has(f)) { this.removeLayer(this._cache.get(f)); this._shown.delete(f); }
|
||||
});
|
||||
want.forEach(f => {
|
||||
if (this._shown.has(f)) return;
|
||||
const layer = this._layerFor(f);
|
||||
if (layer) { this.addLayer(layer); this._shown.add(f); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Below these zooms the OSM/county layers aren't drawn at all.
|
||||
function viewportMinZoom(geojson) {
|
||||
const first = (geojson.features || []).find(f => f && f.geometry);
|
||||
return first && first.geometry.type === 'Point' ? 15 : 13;
|
||||
}
|
||||
|
||||
// Mobile controls state
|
||||
let loadedCounty = '';
|
||||
let loadedDataType = '';
|
||||
@@ -355,44 +470,21 @@ function clearSelection() {
|
||||
|
||||
// Calculate bounds for all loaded layers
|
||||
function calculateBounds() {
|
||||
const bounds = L.latLngBounds([]);
|
||||
let hasData = false;
|
||||
|
||||
if (osmData && osmData.features.length > 0) {
|
||||
L.geoJSON(osmData).eachLayer(layer => {
|
||||
if (layer.getBounds) {
|
||||
bounds.extend(layer.getBounds());
|
||||
} else if (layer.getLatLng) {
|
||||
bounds.extend(layer.getLatLng());
|
||||
}
|
||||
});
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
if (diffData && diffData.features.length > 0) {
|
||||
L.geoJSON(diffData).eachLayer(layer => {
|
||||
if (layer.getBounds) {
|
||||
bounds.extend(layer.getBounds());
|
||||
} else if (layer.getLatLng) {
|
||||
bounds.extend(layer.getLatLng());
|
||||
}
|
||||
});
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
if (countyData && countyData.features.length > 0) {
|
||||
L.geoJSON(countyData).eachLayer(layer => {
|
||||
if (layer.getBounds) {
|
||||
bounds.extend(layer.getBounds());
|
||||
} else if (layer.getLatLng) {
|
||||
bounds.extend(layer.getLatLng());
|
||||
}
|
||||
});
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
if (hasData && bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
// Scan coordinates directly; building throwaway Leaflet layers for
|
||||
// hundreds of thousands of features just to measure them is slow.
|
||||
let minLat = Infinity, minLng = Infinity, maxLat = -Infinity, maxLng = -Infinity;
|
||||
const visit = c => {
|
||||
if (typeof c[0] === 'number') {
|
||||
if (c[1] < minLat) minLat = c[1]; if (c[1] > maxLat) maxLat = c[1];
|
||||
if (c[0] < minLng) minLng = c[0]; if (c[0] > maxLng) maxLng = c[0];
|
||||
} else c.forEach(visit);
|
||||
};
|
||||
[osmData, diffData, countyData].forEach(data => {
|
||||
if (!data) return;
|
||||
data.features.forEach(f => { if (f && f.geometry && f.geometry.coordinates) visit(f.geometry.coordinates); });
|
||||
});
|
||||
if (minLat !== Infinity) {
|
||||
map.fitBounds([[minLat, minLng], [maxLat, maxLng]], { padding: [50, 50] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,13 +629,15 @@ function createOsmLayer() {
|
||||
|
||||
if (!osmData) return;
|
||||
|
||||
osmLayer = L.geoJSON(osmData, {
|
||||
osmLayer = new ViewportGeoJSON(osmData, {
|
||||
layerName: 'osm',
|
||||
minZoom: viewportMinZoom(osmData),
|
||||
style: osmStyle,
|
||||
filter: shouldShowOsmFeature,
|
||||
pane: 'osmPane',
|
||||
renderer: paneRenderer('osmPane'),
|
||||
pointToLayer: function(feature, latlng) {
|
||||
return L.circleMarker(latlng, osmMarkerStyle(feature));
|
||||
return L.circleMarker(latlng, { ...osmMarkerStyle(feature), pane: 'osmPane', renderer: paneRenderer('osmPane') });
|
||||
},
|
||||
onEachFeature: function(feature, layer) {
|
||||
const isPoint = feature.geometry.type === 'Point';
|
||||
@@ -642,7 +736,7 @@ function createDiffLayer() {
|
||||
pane: 'diffPane',
|
||||
renderer: paneRenderer('diffPane'),
|
||||
pointToLayer: function(feature, latlng) {
|
||||
return L.circleMarker(latlng, diffMarkerStyle(feature));
|
||||
return L.circleMarker(latlng, { ...diffMarkerStyle(feature), pane: 'diffPane', renderer: paneRenderer('diffPane') });
|
||||
},
|
||||
onEachFeature: function(feature, layer) {
|
||||
const isPoint = feature.geometry.type === 'Point';
|
||||
@@ -713,13 +807,15 @@ function createCountyLayer() {
|
||||
|
||||
if (!countyData) return;
|
||||
|
||||
countyLayer = L.geoJSON(countyData, {
|
||||
countyLayer = new ViewportGeoJSON(countyData, {
|
||||
layerName: 'county',
|
||||
minZoom: viewportMinZoom(countyData),
|
||||
style: countyStyle,
|
||||
filter: shouldShowCountyFeature,
|
||||
pane: 'countyPane',
|
||||
renderer: paneRenderer('countyPane'),
|
||||
pointToLayer: function(feature, latlng) {
|
||||
return L.circleMarker(latlng, countyMarkerStyle(feature));
|
||||
return L.circleMarker(latlng, { ...countyMarkerStyle(feature), pane: 'countyPane', renderer: paneRenderer('countyPane') });
|
||||
},
|
||||
onEachFeature: function(feature, layer) {
|
||||
const isPoint = feature.geometry.type === 'Point';
|
||||
|
||||
Reference in New Issue
Block a user