Files
gods-eye-view/src/data/neighborhoodPolygons.js
T
Bilawal Sidhu 41b2475362 Merge main into feat/voice-outlines
Adopts main's loadBundledJson(url, {signal}) and builds the Census and
Who's On First pack URLs when they are read, not at import.
2026-10-03 23:09:03 -07:00

219 lines
7.7 KiB
JavaScript

/**
* Bundled neighborhood-polygon lookup — a reliable, deterministic, offline source for
* neighborhood boundaries that OSM tags as label-nodes-only (Chinatown, the Marina, the
* Mission, …). It sits AHEAD of the live-Overpass / synthesis path in the resolver, so
* covered neighborhoods resolve instantly to a REAL boundary with no network dependency
* (the live-Overpass path is slow/flaky for neighborhoods — see docs/field-test-2-analysis.md).
*
* Source-agnostic: each city is a `{name, geometry}` GeoJSON file in
* `local_data/neighborhoods/`. Swap the file (e.g. to public-domain DataSF) without
* touching this module — see `local_data/neighborhoods/SOURCE.md`.
*/
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
// bbox = [west, south, east, north]; only load a city file when the point falls in its box.
const CITY_FILES = [
{
id: 'san-francisco',
bbox: [-122.55, 37.7, -122.35, 37.84],
url: new URL(
'./local_data/neighborhoods/san-francisco.json',
import.meta.url,
),
},
];
/**
* city id → memoized loader. Failures are NOT memoized: a transient
* chunk-load error used to cache an empty array forever, silently demoting
* every later neighborhood ask to the slow live-Overpass ladder for the rest
* of the session, indistinguishably from "not in a covered city".
* @type {Map<string, () => Promise<Array>>}
*/
const _cityLoaders = new Map();
function normalize(s) {
return String(s || '')
.toLowerCase()
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Normalized-query aliases for names DataSF's taxonomy renamed or absorbed.
* "Downtown/Civic Center" was split across Tenderloin/Hayes Valley/etc. and
* deliberately has NO alias — a wrong confident polygon is worse than the
* live resolver ladder ("Downtown" alone colloquially means FiDi, so it maps).
*/
const NAME_ALIASES = new Map([
['financial district', 'financial district south beach'],
['downtown', 'financial district south beach'],
]);
const FILLER_WORDS = new Set([
'the',
'district',
'neighborhood',
'neighbourhood',
]);
/** Whether a feature's words are all of the query's, ignoring filler words. */
function sameWords(featureWords, queryWords) {
const f = new Set(featureWords.filter((w) => !FILLER_WORDS.has(w)));
const q = [...queryWords].filter((w) => !FILLER_WORDS.has(w));
return q.length === f.size && q.every((w) => f.has(w));
}
/** Ray-casting point-in-ring. ring = [[lon,lat], …]. */
function pointInRing(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersect =
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
/** Normalize Polygon | MultiPolygon → array of polygons (each = [outerRing, ...holes]). */
function toPolygons(geom) {
if (!geom) return [];
if (geom.type === 'Polygon') return [geom.coordinates];
if (geom.type === 'MultiPolygon') return geom.coordinates;
return [];
}
/** The outer ring of the polygon (part) that CONTAINS the point, honoring holes. */
function containingOuterRing(feature, lon, lat) {
for (const poly of toPolygons(feature.geometry)) {
const outer = poly[0];
if (!outer || outer.length < 3) continue;
if (!pointInRing(lon, lat, outer)) continue;
let inHole = false;
for (let h = 1; h < poly.length; h++) {
if (pointInRing(lon, lat, poly[h])) {
inHole = true;
break;
}
}
if (!inHole) return outer;
}
return null;
}
/** Largest outer ring (by vertex count) — used when the name matches but the point is just outside. */
function largestOuterRing(geom) {
let best = null;
for (const poly of toPolygons(geom)) {
const outer = poly[0];
if (outer && (!best || outer.length > best.length)) best = outer;
}
return best;
}
function cityLoader(city) {
let loader = _cityLoaders.get(city.id);
if (!loader) {
loader = createRetryableLoader(async () => {
const fc = await loadBundledJson(city.url);
return Array.isArray(fc.features) ? fc.features : [];
});
_cityLoaders.set(city.id, loader);
}
return loader;
}
async function loadCity(city) {
try {
return await cityLoader(city)();
} catch (error) {
// The caller falls back to the live resolver ladder, which looks exactly
// like "no bundled coverage" — say so once, out loud, instead.
console.warn(
`[neighborhoods] ${city.id} pack unavailable:`,
error?.message || error,
);
return [];
}
}
/**
* Look up a bundled neighborhood polygon for a point + name.
* @param {number} lat
* @param {number} lon
* @param {string} matchName - the geocoder's canonical place name (e.g. "Marina District")
* @param {{exact?: boolean}} [options] - `exact`: the name must be the whole ask
* (ignoring "the", "district", "neighborhood"), for asks no geocoder has typed
* ("the Mission District" matches Mission; "Mission Dolores Park" does not).
* @returns {Promise<{ring:[number,number][], name:string}|null>} the outer ring + matched
* neighborhood name, or null when the point isn't in a covered city / no match.
*/
export async function lookupNeighborhoodRing(
lat,
lon,
matchName,
{ exact = false } = {},
) {
const city = CITY_FILES.find(
(c) =>
lon >= c.bbox[0] &&
lat >= c.bbox[1] &&
lon <= c.bbox[2] &&
lat <= c.bbox[3],
);
if (!city) return null;
const feats = await loadCity(city);
if (!feats.length) return null;
// Old/colloquial names whose DataSF taxonomy equivalent has a longer name
// (P0-2 swap): the word-subset matcher requires every feature-name word in
// the query, so "Financial District" can never match "Financial
// District/South Beach" without this. Exact-normalized-key aliases only —
// no fuzzy aliasing.
const q = normalize(matchName);
const aliased = NAME_ALIASES.get(q) || q;
const qWords = new Set(aliased.split(' ').filter(Boolean));
if (!qWords.size) return null;
// A NAME match is REQUIRED — there is NO name-less point-in-polygon fallback. A SF point
// in an UNbundled neighborhood (Hayes Valley, Tenderloin, …) would otherwise fall inside a
// neighbor's bundled polygon and be returned as a confident WRONG boundary, blocking the
// OSM/synthesis ladder. Word-level match (every feature-name word must be in the query) so
// "Mission" matches "Mission District" but NOT "Outer Mission", avoids substring
// false-matches ("mission" inside "transmission"), and disambiguates Presidio vs Presidio
// Heights. Point-in-polygon is only a CONFIRMATION / specificity tiebreak.
let best = null; // { ring, name, contains, fwordCount }
for (const f of feats) {
const fname = normalize(f.properties && f.properties.name);
if (!fname) continue;
const fWords = fname.split(' ').filter(Boolean);
if (!fWords.length || !fWords.every((w) => qWords.has(w))) continue; // require name match
if (exact && !sameWords(fWords, qWords)) continue;
const containRing = containingOuterRing(f, lon, lat);
const ring = containRing || largestOuterRing(f.geometry);
if (!ring) continue;
const cand = {
ring,
name: f.properties.name,
contains: !!containRing,
fwordCount: fWords.length,
};
// Prefer a feature that CONTAINS the point, then the most specific name (most words).
if (
!best ||
(cand.contains && !best.contains) ||
(cand.contains === best.contains && cand.fwordCount > best.fwordCount)
) {
best = cand;
}
}
return best ? { ring: best.ring, name: best.name } : null;
}