#!/usr/bin/env node /** * Build src/data/local_data/neighborhoods/san-francisco.json from the official * public-domain DataSF "Analysis Neighborhoods" dataset. * * Source (map view): https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h * Underlying dataset: https://data.sfgov.org/d/j2bu-swwd (columns: the_geom, nhood) * Fetched from: https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100 * License: Open Data Commons Public Domain Dedication and License (PDDL 1.0) * (dataset metadata `licenseId: "PDDL"` at * https://data.sfgov.org/api/views/j2bu-swwd.json) * * Transform (deterministic): * 1. `properties.nhood` → `properties.name` (no renames — DataSF names kept verbatim). * 2. Douglas-Peucker simplification per ring, tolerance 2e-5° (~2 m) — shaves * hyper-detailed shoreline vertices without visibly moving boundaries. * 3. Coordinates rounded to 6 decimals (~0.1 m); consecutive duplicates dropped; * rings re-closed; rings that collapse below 4 points dropped. * 4. Single-part MultiPolygons collapsed to Polygon. * 5. Features sorted by name for a stable diff. * * Usage: * node scripts/build-sf-neighborhoods.mjs [raw.geojson] * With no argument it downloads the live dataset; with an argument it reads the * given raw GeoJSON file (the exact bytes retrieved on 2026-07-30 in our case). */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const SOURCE_URL = 'https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100'; const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'data', 'local_data', 'neighborhoods', 'san-francisco.json'); const TOLERANCE = 2e-5; // degrees, ~2 m const DECIMALS = 6; /** Perpendicular distance from point p to segment a-b (in degrees, planar — fine at this scale). */ function segDist(p, a, b) { let [x, y] = p; const [x1, y1] = a; const [x2, y2] = b; let dx = x2 - x1; let dy = y2 - y1; if (dx !== 0 || dy !== 0) { const t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy); if (t > 1) { x -= x2; y -= y2; return Math.hypot(x, y); } if (t > 0) { x -= x1 + dx * t; y -= y1 + dy * t; return Math.hypot(x, y); } } return Math.hypot(x - x1, y - y1); } /** Iterative Douglas-Peucker on an open point list. */ function douglasPeucker(points, tolerance) { if (points.length <= 2) return points.slice(); const keep = new Uint8Array(points.length); keep[0] = keep[points.length - 1] = 1; const stack = [[0, points.length - 1]]; while (stack.length) { const [first, last] = stack.pop(); let maxDist = 0; let index = -1; for (let i = first + 1; i < last; i++) { const d = segDist(points[i], points[first], points[last]); if (d > maxDist) { maxDist = d; index = i; } } if (maxDist > tolerance && index !== -1) { keep[index] = 1; stack.push([first, index], [index, last]); } } return points.filter((_, i) => keep[i]); } const round = (v) => Number(v.toFixed(DECIMALS)); /** Simplify + round one ring; returns null if it degenerates. */ function processRing(ring) { // GeoJSON rings are closed (first == last); simplify the open part. const open = ring.length > 1 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1] ? ring.slice(0, -1) : ring.slice(); const simplified = douglasPeucker(open, TOLERANCE).map(([lon, lat]) => [round(lon), round(lat)]); const out = []; for (const pt of simplified) { const prev = out[out.length - 1]; if (prev && prev[0] === pt[0] && prev[1] === pt[1]) continue; out.push(pt); } if (out.length < 3) return null; out.push([out[0][0], out[0][1]]); // re-close return out; } async function main() { let rawText; if (process.argv[2]) { rawText = fs.readFileSync(process.argv[2], 'utf8'); console.log(`read ${process.argv[2]}`); } else { console.log(`fetching ${SOURCE_URL}`); const res = await fetch(SOURCE_URL); if (!res.ok) throw new Error(`HTTP ${res.status} from DataSF`); rawText = await res.text(); } const raw = JSON.parse(rawText); if (raw.type !== 'FeatureCollection' || !Array.isArray(raw.features)) { throw new Error('unexpected payload: not a FeatureCollection'); } let inVerts = 0; let outVerts = 0; const features = []; for (const f of raw.features) { const name = f.properties && f.properties.nhood; if (!name || !f.geometry) throw new Error(`feature missing nhood/geometry: ${JSON.stringify(f.properties)}`); const polysIn = f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates : f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : null; if (!polysIn) throw new Error(`unexpected geometry type ${f.geometry.type} for ${name}`); const polys = []; for (const poly of polysIn) { const rings = []; for (const ring of poly) { inVerts += ring.length; const r = processRing(ring); if (r) { rings.push(r); outVerts += r.length; } } if (rings.length && poly[0] && rings[0]) polys.push(rings); } if (!polys.length) throw new Error(`geometry collapsed for ${name}`); const geometry = polys.length === 1 ? { type: 'Polygon', coordinates: polys[0] } : { type: 'MultiPolygon', coordinates: polys }; features.push({ type: 'Feature', properties: { name }, geometry }); } features.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); const out = { type: 'FeatureCollection', city: 'San Francisco', source: 'DataSF "Analysis Neighborhoods" (dataset j2bu-swwd, map view p5b7-5n3h) — PDDL 1.0 (public domain); see SOURCE.md', features, }; fs.writeFileSync(OUT, `${JSON.stringify(out)}\n`); const kb = (fs.statSync(OUT).size / 1024).toFixed(0); console.log(`wrote ${OUT}: ${features.length} features, ${inVerts} → ${outVerts} vertices, ${kb} KB`); } main().catch((err) => { console.error(err); process.exit(1); });