Fix layer toggles: malformed GeoJSON geometry + fragile layer loading
All checks were successful
CI / test-and-build (push) Successful in 47s

Root cause: osm-data.mjs emitted ways with 'out center' (no geometry) as a
flat [lon,lat] labeled 'LineString'. Leaflet reads those as two invalid
points and threw 'Invalid LatLng', which aborted the entire layer load loop
in onMount -- so no layers rendered and the checkboxes all showed unchecked.
Toggling then appeared to do nothing.

Fixes:
- osm-data.mjs: coordsFor/geometryFor now emit a proper Point for any flat
  [lon,lat] (from nodes / 'out center' ways) instead of a malformed LineString
- MapView.svelte: isolate each layer's load + each feature's searchable build
  in try/catch so one bad feature/layer can't break all layers; skip invalid
  bounds in fitBounds
- osm-data.mjs: preserve manifest entries it doesn't generate (e.g. dump
  layers from osm-dump.mjs) so data/osm-dump scripts don't clobber each other
- scripts/test.mjs: add geometry-integrity checks (no malformed coords)

Verified in a real browser: all 6 layers render, each toggles off/on
independently (33361->0 overlays), and the Directions panel loads.
This commit is contained in:
hermes-explorigin 2026-08-09 21:35:47 +00:00
parent edd6ba9ae2
commit 81a3da388e
7 changed files with 92 additions and 20 deletions

23
package-lock.json generated
View File

@ -18,7 +18,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tsx": "^4.23.11",
"tsx": "^4.19.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
@ -915,6 +915,18 @@
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@ -2320,6 +2332,15 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -116,6 +116,15 @@ function coordsFor(el) {
function geometryFor(el) {
const coords = coordsFor(el);
if (!coords) return null;
// A flat [lon, lat] pair (from a node, or a way's "out center") is a Point,
// not a LineString. Leaflet would otherwise mis-read it as two points and
// throw "Invalid LatLng".
if (typeof coords[0] === 'number') {
if (coords.length >= 2 && typeof coords[1] === 'number') {
return { type: 'Point', coordinates: [coords[0], coords[1]] };
}
return null;
}
// Preserve polygons for closed ways when we have full geometry.
if (el.type === 'way' && el.geometry && el.geometry.length > 2 &&
coords[0][0] === coords[coords.length - 1][0] &&
@ -294,7 +303,20 @@ async function main() {
const signal = new AbortController().signal;
await mkdir(OUT_DIR, { recursive: true });
const manifest = [];
// Preserve manifest entries we are not (re)generating (e.g. dump layers built
// by osm-dump.mjs) so running one script doesn't clobber the other's layers.
let existing = [];
try {
const { readFile } = await import('node:fs/promises');
const { existsSync } = await import('node:fs');
const mp = resolve(OUT_DIR, '_index.json');
if (existsSync(mp)) existing = JSON.parse(await readFile(mp, 'utf8'));
} catch { existing = []; }
const toast = new Set();
for (const [name, cfg] of Object.entries(AREAS)) toast.add(name);
for (const l of CUSTOM_LAYERS) toast.add(l.id);
const manifest = existing.filter((m) => !toast.has(m.name));
for (const [name, cfg] of Object.entries(AREAS)) {
if (only && name !== only) continue;
manifest.push(await buildArea(name, cfg, signal));

View File

@ -91,6 +91,24 @@ async function run() {
}
// 2.4 GeoJSON geometry integrity: no malformed coords Leaflet would choke on.
for (const layer of manifest) {
const fc = loadJson(resolve(STATIC, layer.path.replace(/^\/data\//, '')));
let badLine = 0, badPoint = 0;
for (const f of fc.features) {
const g = f.geometry || {};
const c = g.coordinates;
if (g.type === 'LineString' && Array.isArray(c) && c.length && typeof c[0] === 'number') badLine++;
if (g.type === 'Point' && Array.isArray(c) && (c.length < 2 || typeof c[0] !== 'number')) badPoint++;
if (g.type === 'Polygon') {
const ring = Array.isArray(c) ? c[0] : null;
if (!ring || !ring.length || typeof ring[0] === 'number') badLine++;
}
}
check(badLine === 0, `geometry: "${layer.name}" has no malformed LineString/Polygon coords`);
check(badPoint === 0, `geometry: "${layer.name}" has no malformed Point coords`);
}
// 2.5 Routing graph validation (dump sources).
for (const layer of manifest) {
if (layer.category !== 'dump') continue;

View File

@ -191,6 +191,7 @@
try {
for (const layer of layers) {
try {
const res = await fetch(layer.path);
if (!res.ok) throw new Error(`fetch ${layer.path}: HTTP ${res.status}`);
const fc: FeatureCollection = await res.json();
@ -238,7 +239,9 @@
lay.bindPopup(popupHtml(f));
featureLayers[f.id as string] = lay;
// Build a searchable record.
// Build a searchable record. A single malformed feature
// must not break the whole layer load.
try {
let lat, lon, bounds;
if (f.geometry.type === 'Point') {
[lon, lat] = f.geometry.coordinates as [number, number];
@ -269,21 +272,29 @@
bounds,
feature: f
});
} catch (e) {
console.warn('skip bad feature', f.id, e);
}
}
});
layerGroups[layer.name] = geo;
visible[layer.name] = true;
geo.addTo(map);
} catch (e) {
console.warn('layer load failed:', layer.name, e);
}
}
const all: L.Layer[] = Object.values(layerGroups);
if (all.length) {
const group = L.featureGroup(all);
if (typeof group.getBounds === 'function' && group.getBounds().isValid()) {
map.fitBounds(group.getBounds(), { padding: [40, 40] });
}
}
} catch (e) {
error = (e as Error).message;
console.warn('overall load error:', e);
}
loading = false;
});

View File

@ -1,4 +1,16 @@
[
{
"name": "tulsa-dump",
"label": "Tulsa OSM (dump)",
"category": "dump",
"layerType": "poi",
"color": "#8b5cf6",
"featureCount": 12853,
"path": "/data/tulsa-dump-poi.geojson",
"poiPath": "/data/tulsa-dump-poi.geojson",
"graphPath": "/data/tulsa-dump-graph.json",
"graphNodeCount": 55334
},
{
"name": "tulsa",
"label": "Tulsa, Oklahoma",
@ -10,7 +22,7 @@
36.3,
-95.7
],
"featureCount": 20261,
"featureCount": 20496,
"path": "/data/tulsa.geojson"
},
{
@ -48,17 +60,5 @@
"color": "#f59e0b",
"featureCount": 7,
"path": "/data/custom/rogers-county.geojson"
},
{
"name": "tulsa-dump",
"label": "Tulsa OSM (dump)",
"category": "dump",
"layerType": "poi",
"color": "#8b5cf6",
"featureCount": 12853,
"path": "/data/tulsa-dump-poi.geojson",
"poiPath": "/data/tulsa-dump-poi.geojson",
"graphPath": "/data/tulsa-dump-graph.json",
"graphNodeCount": 55334
}
]

File diff suppressed because one or more lines are too long