mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Adopts main's loadBundledJson(url, {signal}) and builds the Census and
Who's On First pack URLs when they are read, not at import.
219 lines
7.7 KiB
JavaScript
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;
|
|
}
|