The contract, published for B and C in LANE_A_NOTES §39 (first commit, per the half-day rule).
createAddresses(plan, cache|null) → streetOf(edgeId) · localityOf(shopId) · cohort(pred)
· streets() · stats()
New pure module web/js/citygen/address.js: ZERO imports, no THREE, no fetch, no DOM, no module
state, no plan mutation. Two suppliers, ONE consumer contract — a real town fills `label` from the
cache's named ways, the synthetic fills it from district.kind + block. Consumers must never branch
on town type; that constraint is the design.
MEASURED (Fable's binding facts re-derived independently, all three land exactly):
· 19,132 road ways, 17,835 named (93.2%)
· median plan-edge-sample → nearest named way 0.03–0.90 m (worst katoomba)
· 1,192 / 1,219 corpus shops resolve to a street name (97.8%); 29,865/30,986 edges named (96.4%)
· all 27 unresolved shops front a way OSM genuinely leaves unnamed (adelaide 21 arcade) — null is
the honest answer and the module returns it rather than borrowing a neighbour's name
TWO DEPARTURES FROM THE SYNTHESIS, both with numbers:
· the shift is recovered EXACTLY (node↔waypoint lattice intersection), not voted for. The proposed
centroid-align + nearest-waypoint vote returns the WRONG shift on 5 of 23 towns (braddon,
fremantle, hobart, northbridge, westend) — island culling drags the plan centroid off the
cache's. A wrong shift renames every street in the town. Exact on 23/23, nothing to tune.
· the resolver samples FIVE points along an edge, not the midpoint — a midpoint cannot tell a
street from the street that crosses it.
THE ONE-LINE UPGRADE: TAKEN. plan_osm.js writes norm.shift = {shx,shz} into the normalization log,
reaching the caller only via the existing opts.report sink — never onto the plan. It is a
cross-check, not a dependency: selfcheck compares plan_osm's published shift against address.js's
independently recovered one and fires on a 0.01 m disagreement.
GATES (+240 checks), each proven to fire on a broken world:
· 0 wrong street names on 884 shop-bearing edges, against an INDEPENDENT way-membership resolver
(857 agree, 0 disagree). Corpus-wide 4/30,986 disagree — all 2D-stacked ways, none carrying a
shop — PINNED at 4, not asserted > 0.
· CONTROL: with roads[].name stripped (exactly the pre-change state) the layer resolves 0 streets.
· CONTROL: no cache ⇒ supplier 'district', visible in stats(), never a silent wrong name.
· createAddresses does not mutate the plan — asserted byte-for-byte, per town.
· tolerance 8 m, and the finding that sets it: correctness SATURATES AT 6 m (857 agreeing frontage
names at 6, 8, 12, 16 and 24 m). Every metre past 6 buys only a name borrowed from an unnamed
way; it can never buy a correction.
Additive: getTownCache(key) — index.html registers the cache and drops its reference, so roads[]
was unreachable downstream. Read-only, no new state, no shell change.
GOLDENS: NOTHING MOVED, ZERO RE-PINS. selfcheck 157,407 → 157,647 ALL GREEN, fingerprint 0x5f76e76.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
355 lines
21 KiB
JavaScript
355 lines
21 KiB
JavaScript
// PROCITY CityGen — THE ADDRESS LAYER (Lane A, ROUND39 item 39.1). `createAddresses(plan, cache)`.
|
||
//
|
||
// PURE. No THREE, no fetch, no DOM, no plan mutation, no module state. In: a CityPlan (+ the town
|
||
// cache it was lifted from, or null). Out: a small JSON-safe query object. Same laws as the rest of
|
||
// citygen — deterministic, and it CANNOT move a golden because it never writes to `plan`.
|
||
//
|
||
// ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────────
|
||
// 17,835 of the 19,132 road ways in the shipped caches carry a `name` (93.2% — re-measured this round,
|
||
// exact to the digit). `plan_osm.js:365` builds each way as `rawWays.push({ kind, pl })` and DISCARDS
|
||
// `rd.name`. Every real street in 23 Australian towns has been sitting in this repo with its name on
|
||
// it and the game has never said one out loud. This module says them, without changing plan output.
|
||
//
|
||
// ── THE ONE CONSUMER CONTRACT (this is the point of the module — read this before consuming) ─────
|
||
//
|
||
// createAddresses(plan, cache|null, opts?) → {
|
||
// streetOf(edgeId) → "Templeton Street" | null // real street name, or null. Never a guess.
|
||
// localityOf(shopId) → { shopId, street, district, block, side, label } | null
|
||
// cohort(predicate) → shopId[] // predicate(locality) → bool; ids ascending
|
||
// streets() → [{ name, edges:[edgeId], shops:[shopId] }] // sorted by name
|
||
// stats() → { … } // the measurement, so a gate reads numbers instead of a claim
|
||
// }
|
||
//
|
||
// TWO SUPPLIERS, ONE FIELD. `label` is the string a consumer PRINTS, and it is filled by a different
|
||
// supplier on each kind of town:
|
||
// · a real town (a cache with named ways) → the street name: "Templeton Street"
|
||
// · the synthetic town (no cache, 22 edges) → the district phrase: "the market end", "the arcade",
|
||
// "the backstreets" — from `district.kind` + the block's sector inside that district.
|
||
// **CONSUMERS MUST NEVER BRANCH ON TOWN TYPE.** Do not test `plan.source`, do not test for a cache,
|
||
// do not special-case the synthetic. Print `label`; group by `block`; ask `street` only when you
|
||
// specifically need a *name* and can honestly handle null. That constraint is the whole design: the
|
||
// moment a consumer branches, every feature built on this has to be written twice and the synthetic
|
||
// half rots. (`street` is null on the synthetic town by construction, and null on the ~2% of real-town
|
||
// shops whose frontage resolves to no named way. `label` is null exactly when nothing honest can be
|
||
// said — a real-town shop with no resolvable street. Handle null once, on both town types.)
|
||
//
|
||
// A WRONG STREET NAME IS WORSE THAN NO STREET NAME. The player navigates by this. So the resolver is
|
||
// deliberately conservative: see THE RESOLUTION RULE below. Unresolved is `null`, loudly, every time.
|
||
|
||
const isNum = v => typeof v === 'number' && Number.isFinite(v);
|
||
const EARTH_M = 111320; // metres per degree latitude — plan_osm.js:24's equirectangular convention
|
||
const cents = v => Math.round(v * 100); // plan_osm rounds every coord to 2dp (r2); work on that integer lattice
|
||
|
||
// ── THE TOLERANCE ───────────────────────────────────────────────────────────────────────────────
|
||
// 8 m, as briefed — but the number that decided it is not the coverage curve, it is the CONTROL.
|
||
// Plan nodes ARE snapped projected way points (plan_osm.js:361-372, SNAP=3), so a plan edge lies ON
|
||
// its way: median sample→way distance is 0.03–0.90 m across the 23 caches (worst katoomba). The real
|
||
// bound the tolerance has to clear is Douglas–Peucker chord error (EPS = 6 m, plan_osm.js:353), not
|
||
// the median.
|
||
//
|
||
// Measured, whole corpus, corpus shops that get a street name:
|
||
// 2 m → 85.2% 4 m → 94.8% 6 m → 97.4% 8 m → 97.8% 12 m → 99.1% 16 m → 99.2%
|
||
// and the share of resolved edges where TWO different names both qualified at all five samples:
|
||
// 2 m → 0.23% 4 m → 3.21% 6 m → 7.27% 8 m → 10.96% 12 m → 16.59% 16 m → 20.68%
|
||
//
|
||
// THE FINDING THAT ACTUALLY SETS THE KNOB — checked against the independent way-membership control
|
||
// (see LANE_A_NOTES §39): over the 884 shop-bearing edges, the number that resolve to the SAME name
|
||
// the control derives is **857 at 6 m and 857 at 8, 12, 16 and 24 m**. Correctness SATURATES AT 6 m.
|
||
// Every metre past 6 buys exactly one thing: a name attached to a way OSM left unnamed (0 such edges
|
||
// at 6 m, 4 at 8 m, 12 at 12 m, 14 at 24 m). It can never buy a correction. So raising this knob to
|
||
// chase coverage is always borrowing a neighbour's name, and should be argued as that, never as
|
||
// accuracy. 8 m keeps the four borrowings — all arcade footways inside a named mall (bowral's
|
||
// "Corbett Plaza" ×2, fremantle's "High Street" ×2), where the borrowed name is what a person would
|
||
// actually say. `opts.tolerance: 6` is the strictly-conservative setting at identical correctness.
|
||
export const STREET_TOLERANCE_M = 8;
|
||
|
||
// ── THE RESOLUTION RULE (the reason this is not "nearest way to the midpoint") ──────────────────
|
||
// The brief says "nearest named way to the edge midpoint". A midpoint alone cannot tell a street from
|
||
// the street that CROSSES it: at an intersection both are ~0 m away, and picking either by a hair is
|
||
// exactly the wrong-name failure the player would navigate by. So a name must be within tolerance at
|
||
// FIVE samples spread along the edge, not at one point. A crossing street is close at one sample and
|
||
// far at the rest; the edge's own street is ~0 m at all five. Endpoints are excluded (t ∈ [0.15,0.85])
|
||
// because they ARE the junctions. Among names that qualify at every sample, the smallest worst-case
|
||
// distance wins; a tie inside 0.25 m (genuinely coincident ways) is resolved by name, deterministically.
|
||
const SAMPLE_TS = [0.15, 0.3, 0.5, 0.7, 0.85];
|
||
|
||
const DISTRICT_PHRASE = {
|
||
mainstreet: 'the main street',
|
||
market: 'the market end',
|
||
arcade: 'the arcade',
|
||
backstreets: 'the backstreets',
|
||
warehouse: 'the warehouse fringe',
|
||
residential: 'the residential streets',
|
||
};
|
||
const SECTOR = ['east', 'north', 'west', 'south']; // index = quadrant of atan2, see sectorOf
|
||
|
||
// Nearest point on segment AB to P, squared distance. (Same maths as selfcheck's nearestOnSeg.)
|
||
function segDist2(px, pz, ax, az, bx, bz) {
|
||
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz;
|
||
let t = L2 ? ((px - ax) * dx + (pz - az) * dz) / L2 : 0;
|
||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||
const qx = px - (ax + dx * t), qz = pz - (az + dz * t);
|
||
return qx * qx + qz * qz;
|
||
}
|
||
|
||
// Cardinal sector of a vector, as a word. Two opposite vectors always map to two opposite words, so
|
||
// the two sides of any street get two distinct, stable tokens whatever its bearing.
|
||
function sectorOf(dx, dz) {
|
||
return Math.abs(dx) >= Math.abs(dz) ? (dx >= 0 ? 'east' : 'west') : (dz >= 0 ? 'north' : 'south');
|
||
}
|
||
|
||
// ── THE SHIFT ───────────────────────────────────────────────────────────────────────────────────
|
||
// plan_osm.js:502-506 centres the imported town on the origin with a rigid translation
|
||
// (`shx`,`shz`) applied AFTER the graph is built, so: plan node = r2(projected way point) + shift,
|
||
// exactly, on the 2dp lattice. To compare a plan edge against a cache way we must undo it.
|
||
//
|
||
// It is recovered EXACTLY, not estimated. Take one plan node; every (node − way point) difference is
|
||
// a candidate; keep only candidates under which further plan nodes ALSO land exactly on way points.
|
||
// Two or three nodes collapse ~25,000 candidates to one, because a real road network has no
|
||
// centimetre-exact translational symmetry. Measured: **exact on 23 of 23 caches**, no tuning, and it
|
||
// returns null rather than a guess when it cannot prove an answer.
|
||
//
|
||
// This is deliberately NOT the centroid-align + nearest-waypoint vote the synthesis describes. I
|
||
// implemented that first and it landed on the WRONG shift on 5 of the 23 towns (braddon, fremantle,
|
||
// hobart, northbridge, westend) — the cull of shopless islands (plan_osm.js:407-414) drags the plan's
|
||
// centroid off the cache's, the initial alignment is then tens of metres out, and the vote converges
|
||
// confidently onto a wrong offset. A wrong shift is not a degraded answer: it renames EVERY street in
|
||
// the town. A tuned vote can no doubt be made to work; an exact recovery has nothing to tune, so it
|
||
// is the one I shipped. `opts.shift` (e.g. `report.shift` from `generatePlanOSM(seed, town, {report})`)
|
||
// short-circuits it and is CROSS-CHECKED against it — `stats().shiftCheck` reports 'agree'/'DISAGREE'.
|
||
function recoverShift(plan, projPointKeys, sampleNodes) {
|
||
if (!sampleNodes.length || !projPointKeys.size) return null;
|
||
for (let anchor = 0; anchor < Math.min(4, sampleNodes.length); anchor++) {
|
||
const n0 = sampleNodes[anchor];
|
||
let cands = [];
|
||
for (const k of projPointKeys) {
|
||
const c = k.indexOf(',');
|
||
cands.push([cents(n0.x) - +k.slice(0, c), cents(n0.z) - +k.slice(c + 1)]);
|
||
}
|
||
for (let i = 0; i < sampleNodes.length && cands.length > 1; i++) {
|
||
if (i === anchor) continue;
|
||
const n = sampleNodes[i], nx = cents(n.x), nz = cents(n.z);
|
||
const kept = cands.filter(([sx, sz]) => projPointKeys.has(`${nx - sx},${nz - sz}`));
|
||
if (!kept.length) break; // this anchor was not itself a way point
|
||
cands = kept;
|
||
}
|
||
if (cands.length === 1) return { shx: cands[0][0] / 100, shz: cands[0][1] / 100, source: 'recovered' };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function createAddresses(plan, cache = null, opts = {}) {
|
||
const TOL = isNum(opts.tolerance) && opts.tolerance > 0 ? opts.tolerance : STREET_TOLERANCE_M;
|
||
const TOL2 = TOL * TOL;
|
||
const edges = (plan && plan.streets && Array.isArray(plan.streets.edges)) ? plan.streets.edges : [];
|
||
const nodes = (plan && plan.streets && Array.isArray(plan.streets.nodes)) ? plan.streets.nodes : [];
|
||
const lots = (plan && Array.isArray(plan.lots)) ? plan.lots : [];
|
||
const shops = (plan && Array.isArray(plan.shops)) ? plan.shops : [];
|
||
const blocks = (plan && Array.isArray(plan.blocks)) ? plan.blocks : [];
|
||
const districts = (plan && Array.isArray(plan.districts)) ? plan.districts : [];
|
||
|
||
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
||
const edgeById = new Map(edges.map(e => [e.id, e]));
|
||
const lotById = new Map(lots.map(l => [l.id, l]));
|
||
const blockById = new Map(blocks.map(b => [b.id, b]));
|
||
const districtById = new Map(districts.map(d => [d.id, d]));
|
||
|
||
const roads = (cache && Array.isArray(cache.roads)) ? cache.roads : [];
|
||
const hasCentre = !!(cache && cache.center && isNum(cache.center.lat) && isNum(cache.center.lon));
|
||
const namedRoads = hasCentre ? roads.filter(r => r && Array.isArray(r.pts) && r.pts.length >= 2 &&
|
||
typeof r.name === 'string' && r.name.trim()) : [];
|
||
|
||
// ── SUPPLIER A: the real streets, from the cache's named ways ──────────────────────────────────
|
||
const streetByEdge = new Map(); // edgeId → name
|
||
let shift = null, shiftSource = 'none', shiftCheck = null;
|
||
let resolveCandidatesSeen = 0, ambiguousEdges = 0;
|
||
|
||
if (namedRoads.length) {
|
||
const cosLat = Math.cos(cache.center.lat * Math.PI / 180);
|
||
const projX = lon => (lon - cache.center.lon) * EARTH_M * cosLat;
|
||
const projZ = lat => (lat - cache.center.lat) * EARTH_M;
|
||
|
||
// every way point (named or not — a plan node may come from an unnamed way) on the 2dp lattice
|
||
const projPointKeys = new Set();
|
||
for (const rd of roads) {
|
||
if (!rd || !Array.isArray(rd.pts)) continue;
|
||
for (const p of rd.pts) if (Array.isArray(p) && isNum(p[0]) && isNum(p[1])) {
|
||
projPointKeys.add(`${cents(projX(p[1]))},${cents(projZ(p[0]))}`);
|
||
}
|
||
}
|
||
// spread the sample across the node list so one bad island can't own the anchor set
|
||
const step = Math.max(1, Math.floor(nodes.length / 12));
|
||
const sampleNodes = [];
|
||
for (let i = 0; i < nodes.length && sampleNodes.length < 12; i += step) sampleNodes.push(nodes[i]);
|
||
|
||
const given = opts.shift && isNum(opts.shift.shx) && isNum(opts.shift.shz) ? opts.shift : null;
|
||
const rec = recoverShift(plan, projPointKeys, sampleNodes);
|
||
if (given && rec) shiftCheck = (cents(given.shx) === cents(rec.shx) && cents(given.shz) === cents(rec.shz)) ? 'agree' : 'DISAGREE';
|
||
if (given) { shift = { shx: given.shx, shz: given.shz }; shiftSource = 'given'; }
|
||
else if (rec) { shift = { shx: rec.shx, shz: rec.shz }; shiftSource = 'recovered'; }
|
||
|
||
if (shift) {
|
||
// segment grid over the named ways, in CACHE (projected) space
|
||
const CELL = 32;
|
||
const segs = []; // [ax, az, bx, bz, nameIdx]
|
||
const names = [], nameIdx = new Map();
|
||
const grid = new Map();
|
||
for (const rd of namedRoads) {
|
||
const nm = rd.name.trim();
|
||
let ni = nameIdx.get(nm);
|
||
if (ni === undefined) { ni = names.length; names.push(nm); nameIdx.set(nm, ni); }
|
||
const pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1]))
|
||
.map(p => [projX(p[1]), projZ(p[0])]);
|
||
for (let i = 0; i + 1 < pl.length; i++) {
|
||
const si = segs.length;
|
||
segs.push([pl[i][0], pl[i][1], pl[i + 1][0], pl[i + 1][1], ni]);
|
||
const x0 = Math.floor(Math.min(pl[i][0], pl[i + 1][0]) / CELL), x1 = Math.floor(Math.max(pl[i][0], pl[i + 1][0]) / CELL);
|
||
const z0 = Math.floor(Math.min(pl[i][1], pl[i + 1][1]) / CELL), z1 = Math.floor(Math.max(pl[i][1], pl[i + 1][1]) / CELL);
|
||
for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) {
|
||
const k = `${cx},${cz}`; const b = grid.get(k); if (b) b.push(si); else grid.set(k, [si]);
|
||
}
|
||
}
|
||
}
|
||
// name → smallest squared distance within TOL of (x,z); absent = farther than TOL
|
||
const nearNames = (x, z) => {
|
||
const out = new Map();
|
||
const x0 = Math.floor((x - TOL) / CELL), x1 = Math.floor((x + TOL) / CELL);
|
||
const z0 = Math.floor((z - TOL) / CELL), z1 = Math.floor((z + TOL) / CELL);
|
||
for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) {
|
||
const b = grid.get(`${cx},${cz}`); if (!b) continue;
|
||
for (const si of b) {
|
||
const s = segs[si];
|
||
const d2 = segDist2(x, z, s[0], s[1], s[2], s[3]);
|
||
if (d2 > TOL2) continue;
|
||
const cur = out.get(s[4]);
|
||
if (cur === undefined || d2 < cur) out.set(s[4], d2);
|
||
}
|
||
}
|
||
return out;
|
||
};
|
||
|
||
for (const e of edges) {
|
||
const a = nodeById.get(e.a), b = nodeById.get(e.b);
|
||
if (!a || !b) continue;
|
||
const ax = a.x - shift.shx, az = a.z - shift.shz, bx = b.x - shift.shx, bz = b.z - shift.shz;
|
||
// intersect the qualifying-name sets across all five samples, carrying the worst distance
|
||
let worst = null;
|
||
for (let k = 0; k < SAMPLE_TS.length; k++) {
|
||
const t = SAMPLE_TS[k];
|
||
const hit = nearNames(ax + (bx - ax) * t, az + (bz - az) * t);
|
||
if (!hit.size) { worst = null; break; }
|
||
if (worst === null) { worst = hit; continue; }
|
||
const next = new Map();
|
||
for (const [ni, d2] of worst) { const d = hit.get(ni); if (d !== undefined) next.set(ni, d > d2 ? d : d2); }
|
||
if (!next.size) { worst = null; break; }
|
||
worst = next;
|
||
}
|
||
if (!worst || !worst.size) continue;
|
||
resolveCandidatesSeen += worst.size;
|
||
if (worst.size > 1) ambiguousEdges++;
|
||
let bestNi = -1, bestD = Infinity;
|
||
for (const [ni, d2] of worst) {
|
||
const d = Math.sqrt(d2);
|
||
if (d < bestD - 0.25 || (Math.abs(d - bestD) <= 0.25 && bestNi >= 0 && names[ni] < names[bestNi])) {
|
||
bestD = Math.min(d, bestD); bestNi = ni; // ±0.25 m tie → alphabetical, deterministic
|
||
}
|
||
}
|
||
if (bestNi >= 0) streetByEdge.set(e.id, names[bestNi]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── SUPPLIER B: the synthetic town's districts ─────────────────────────────────────────────────
|
||
// No cache, no named ways, 22 edges. The label comes from `district.kind` + the block's sector
|
||
// inside that district, so it is a PLACE ("the market end") rather than a name. Same field.
|
||
const blockLabel = new Map(); // blockId → label
|
||
{
|
||
const perDistrict = new Map();
|
||
for (const b of blocks) {
|
||
const cen = b.poly && b.poly.length
|
||
? b.poly.reduce((a, p) => [a[0] + p[0] / b.poly.length, a[1] + p[1] / b.poly.length], [0, 0])
|
||
: [0, 0];
|
||
const arr = perDistrict.get(b.district); if (arr) arr.push([b, cen]); else perDistrict.set(b.district, [[b, cen]]);
|
||
}
|
||
for (const [did, arr] of perDistrict) {
|
||
const d = districtById.get(did);
|
||
const phrase = (d && DISTRICT_PHRASE[d.kind]) || 'the town';
|
||
if (arr.length === 1) { blockLabel.set(arr[0][0].id, phrase); continue; }
|
||
const cx = arr.reduce((s, [, c]) => s + c[0], 0) / arr.length;
|
||
const cz = arr.reduce((s, [, c]) => s + c[1], 0) / arr.length;
|
||
for (const [b, c] of arr) blockLabel.set(b.id, `the ${sectorOf(c[0] - cx, c[1] - cz)} end of ${phrase}`);
|
||
}
|
||
}
|
||
|
||
const supplier = streetByEdge.size ? 'ways' : 'district';
|
||
|
||
// ── the shop table ─────────────────────────────────────────────────────────────────────────────
|
||
const localityByShop = new Map();
|
||
for (const sh of shops) {
|
||
const lot = lotById.get(sh.lot);
|
||
const block = lot ? blockById.get(lot.block) : null;
|
||
const district = block ? districtById.get(block.district) : null;
|
||
const street = lot && streetByEdge.has(lot.frontEdge) ? streetByEdge.get(lot.frontEdge) : null;
|
||
|
||
// side of the street: the cardinal the lot sits on, off its frontEdge's centreline
|
||
let side = null;
|
||
if (lot) {
|
||
const e = edgeById.get(lot.frontEdge);
|
||
const a = e ? nodeById.get(e.a) : null, b = e ? nodeById.get(e.b) : null;
|
||
if (a && b) {
|
||
const dx = b.x - a.x, dz = b.z - a.z, L2 = dx * dx + dz * dz;
|
||
let t = L2 ? ((lot.x - a.x) * dx + (lot.z - a.z) * dz) / L2 : 0;
|
||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||
side = sectorOf(lot.x - (a.x + dx * t), lot.z - (a.z + dz * t));
|
||
}
|
||
}
|
||
localityByShop.set(sh.id, {
|
||
shopId: sh.id,
|
||
street,
|
||
district: district ? district.kind : null,
|
||
block: lot ? lot.block : null,
|
||
side,
|
||
label: supplier === 'ways' ? street : (lot ? (blockLabel.get(lot.block) || null) : null),
|
||
});
|
||
}
|
||
|
||
const streetIndex = new Map(); // name → { name, edges[], shops[] }
|
||
for (const [eid, nm] of streetByEdge) {
|
||
const rec = streetIndex.get(nm) || streetIndex.set(nm, { name: nm, edges: [], shops: [] }).get(nm);
|
||
rec.edges.push(eid);
|
||
}
|
||
for (const loc of localityByShop.values()) if (loc.street) streetIndex.get(loc.street).shops.push(loc.shopId);
|
||
for (const rec of streetIndex.values()) { rec.edges.sort((a, b) => a - b); rec.shops.sort((a, b) => a - b); }
|
||
|
||
const resolvedShops = [...localityByShop.values()].filter(l => l.label !== null).length;
|
||
const streetedShops = [...localityByShop.values()].filter(l => l.street !== null).length;
|
||
|
||
return {
|
||
streetOf: edgeId => (streetByEdge.has(edgeId) ? streetByEdge.get(edgeId) : null),
|
||
localityOf: shopId => localityByShop.get(shopId) || null,
|
||
cohort(predicate) {
|
||
const out = [];
|
||
for (const loc of localityByShop.values()) { try { if (predicate(loc)) out.push(loc.shopId); } catch { /* a throwing predicate selects nothing */ } }
|
||
return out.sort((a, b) => a - b);
|
||
},
|
||
streets: () => [...streetIndex.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
||
stats: () => ({
|
||
supplier, // 'ways' (a real town) | 'district' (the synthetic)
|
||
tolerance: TOL,
|
||
shift: shift ? { shx: shift.shx, shz: shift.shz } : null,
|
||
shiftSource, // 'given' | 'recovered' | 'none'
|
||
shiftCheck, // 'agree' | 'DISAGREE' | null (only one source present)
|
||
namedWays: namedRoads.length,
|
||
roads: roads.length,
|
||
edges: edges.length,
|
||
edgesNamed: streetByEdge.size,
|
||
ambiguousEdges, // ≥2 names qualified at every sample (tie-broken)
|
||
distinctStreets: streetIndex.size,
|
||
shops: shops.length,
|
||
shopsWithStreet: streetedShops,
|
||
shopsLabelled: resolvedShops,
|
||
candidatesPerResolvedEdge: streetByEdge.size ? resolveCandidatesSeen / streetByEdge.size : 0,
|
||
}),
|
||
};
|
||
}
|