diff --git a/internal/check-approx-pin-note.mjs b/internal/check-approx-pin-note.mjs index 3de198bcc..88f0eedee 100644 --- a/internal/check-approx-pin-note.mjs +++ b/internal/check-approx-pin-note.mjs @@ -29,6 +29,14 @@ check('three groups on one point find each other', colocatedWith(idx, rows[0]).s check('a GeoJSON feature is matched to rows on the same point', colocatedWith(idx, rows[2]).sort().join() === 'Godda,Koraga', colocatedWith(idx, rows[2])) check('a lone point shares with nobody', colocatedWith(idx, rows[3]).length === 0, colocatedWith(idx, rows[3])) check('a row without a position is ignored', colocatedWith(idx, rows[4]).length === 0, colocatedWith(idx, rows[4])) +// A click hands over the RENDERED feature: the engine rebuilt its geometry from tile coordinates, so it is +// near the roster point but not equal to it: ~0.001 degree at low zoom (rega, row 46, a shared point that never matched). +const rendered = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [75.2486, 12.8431] } } +check('a rendered (tile-rounded) feature still finds its co-located groups', colocatedWith(idx, rendered).sort().join() === 'Godda,Maila', colocatedWith(idx, rendered)) +// The same people in two countries: the nearest roster row wins, not the first. +const twin = buildColocationIndex([...rows, { name: 'Koraga', latitude: 40, longitude: 40 }, { name: 'Other', latitude: 40, longitude: 40 }]) +const renderedFar = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [40.0007, 39.9994] } } +check('a repeated name resolves to the nearest roster point', colocatedWith(twin, renderedFar).join() === 'Other', colocatedWith(twin, renderedFar)) const { createServer } = await import('vite') const { default: vue } = await import('@vitejs/plugin-vue') diff --git a/library/utils/colocation.js b/library/utils/colocation.js index cef1e2b4d..659c9b81e 100644 --- a/library/utils/colocation.js +++ b/library/utils/colocation.js @@ -19,24 +19,48 @@ export function pointOf(r) { } const nameOf = (r) => { const p = (r && (r.properties || r)) || {}; return p.name || p.peopleName || p.PeopNameInCountry || '' } -/** Build once per roster: Map. */ +/** Build once per roster: Map, plus `byName` (name -> [[lat, lng, key]]). */ export function buildColocationIndex(rows) { const idx = new Map() + const byName = new Map() for (const r of rows || []) { const pt = pointOf(r); const n = nameOf(r) if (!pt || !n) continue const k = KEY(pt[0], pt[1]) if (!idx.has(k)) idx.set(k, []) idx.get(k).push(n) + if (!byName.has(n)) byName.set(n, []) + byName.get(n).push([pt[0], pt[1], k]) } + idx.byName = byName return idx } +/** + * The roster key for a selected pin. A click hands over the RENDERED feature, whose geometry the engine + * rebuilt from tile coordinates, so it is close to the roster's point but not equal to it at six decimals + * (rega, row 46: a four-group point never matched). So the pin is resolved to its ROSTER ROW by name, and + * when a name repeats (the same people in several countries) to the nearest of those rows. Only a name the + * roster does not know falls back to the pin's own coordinate. + */ +function keyFor(idx, row) { + const pt = pointOf(row) + const cands = idx.byName && idx.byName.get(nameOf(row)) + if (cands && cands.length) { + if (cands.length === 1 || !pt) return cands[0][2] + let best = cands[0], d = Infinity + for (const c of cands) { const dd = (c[0] - pt[0]) ** 2 + (c[1] - pt[1]) ** 2; if (dd < d) { d = dd; best = c } } + return best[2] + } + return pt ? KEY(pt[0], pt[1]) : null +} + /** The OTHER groups on this row's point ([] when the point is not shared). */ export function colocatedWith(idx, row) { - const pt = pointOf(row); if (!idx || !pt) return [] + if (!idx) return [] + const k = keyFor(idx, row); if (!k) return [] const me = nameOf(row) - const all = idx.get(KEY(pt[0], pt[1])) || [] + const all = idx.get(k) || [] const others = all.filter((n) => n !== me) return all.length > 1 ? [...new Set(others)] : [] }