dx-shepherd asked for this with a7b12b44 and the reason is exact: every caller of readSourceFeatures is
written to do NOTHING when it finds no features, deliberately, so that a legend row for an off-screen
region degrades quietly instead of throwing. That is why the MapLibre bug produced no error, no console
line and no visual difference except a camera that did not move — and why it survived until a packet
printed the wrong 50 people.
check:pin-selection drives the reader with fixtures for both engines and no map at all:
· the MAPBOX shape (_data IS the FeatureCollection) reads its features;
· the MAPLIBRE shape (_data is { geojson }) reads the SAME features — the regression that shipped;
· a REMOTE source ({ url }) returns [] AND WARNS;
· the serialize() fallback works, a missing source is [] rather than a throw, a bare Feature is one;
· selectedPinIds ANDs its expressions, and with none returns every pin rather than none.
★ NEGATIVE-TESTED, which is the only thing that makes it worth having: run against the reader as it
shipped, it fails with exactly four findings — the maplibre shape reads 0 instead of 3, selectedPinIds
narrows to nothing, "no expressions" returns nothing, and the remote case returns [] silently. Against
the fixed reader it passes. A test that cannot fail on the code it was written for proves nothing.
★ AND THE REMOTE CASE IS NOW A DECISION, not an accident. dx-shepherd asked what { url } should do. It
returns [] and warns once per source: the features live on a server and cannot be enumerated locally, so
"I cannot read this" is a different fact from "there is nothing here". Collapsing those two is precisely
how the MapLibre bug hid for as long as it did, and no caller can tell them apart from an empty array.
Verified: check:pin-selection, check:mjs-parse, check:doc-scripts, check:package-json-unique-keys all
PASS. gates:pre was NOT run clean here — my main clone sits three landings behind origin/main, so a
newer package.json lists check:targets while that file is absent from the working tree; that failure is
my verification setup, not this change, and I checked it on an untouched tree before reporting.
183 lines
8.7 KiB
JavaScript
183 lines
8.7 KiB
JavaScript
/**
|
|
* pin-selection.js — which pins does a Mapbox/MapLibre filter expression actually select?
|
|
*
|
|
* ★ MOVED, NOT WRITTEN (from useMapFly.js, where it was private). zoomToLegendRow has always had to
|
|
* answer this question to fit the camera to a legend row, and it answers it the only way that is
|
|
* correct: map.querySourceFeatures(id, {filter}) is VIEWPORT-CLIPPED, so pins outside the camera are
|
|
* dropped and an off-screen region silently selects nothing. These read the full source data and
|
|
* evaluate the expression in JS instead.
|
|
*
|
|
* ★ WHY IT HAD TO BE SHARED (PACKETSEL). The research map's prayer packet printed the first 50 of
|
|
* every people group whatever the reader had selected. Choosing the right rows means asking exactly
|
|
* this question — and the naive alternative, evaluating the filter against the raw data rows, is
|
|
* WRONG in a way that would not throw: pin properties are DERIVED from rows (useMapLayers derives
|
|
* languageFamily when the API returns null, canonicalizes doxaRegion onto the palette's key forms),
|
|
* so a filter written against pin properties does not mean the same thing applied to a row. The
|
|
* features the map holds are the only place those two vocabularies agree.
|
|
*
|
|
* country-posters prints the same kind of packet from the same kind of selection, which is why this
|
|
* is in library/ rather than in one plugin.
|
|
*
|
|
* The evaluator covers the expression subset this product's filters use — all/any/!/has/comparisons/
|
|
* in/match, plus get/literal/coalesce/to-number. Anything else evaluates false rather than throwing:
|
|
* a packet that prints fewer rows is recoverable, a map that throws mid-print is not.
|
|
*/
|
|
|
|
// Why: map.querySourceFeatures(sourceId, {filter}) is viewport-clipped —
|
|
// pins outside the rendered camera are dropped, so legend rows for off-screen
|
|
// regions silently no-op when the user is zoomed in. We evaluate the Mapbox
|
|
// filter expression against the full source data instead, so fitBounds can
|
|
// zoom OUT to a different region or IN to a tight cluster uniformly.
|
|
export function resolveExpr(expr, props) {
|
|
if (!Array.isArray(expr)) return expr;
|
|
const op = expr[0];
|
|
switch (op) {
|
|
case 'get': return props?.[expr[1]];
|
|
case 'literal': return expr[1];
|
|
case 'has': return props != null && expr[1] in props;
|
|
case '!has': return props == null || !(expr[1] in props);
|
|
case '!': return !resolveExpr(expr[1], props);
|
|
case 'coalesce': {
|
|
for (let i = 1; i < expr.length; i++) {
|
|
const v = resolveExpr(expr[i], props);
|
|
if (v != null) return v;
|
|
}
|
|
return null;
|
|
}
|
|
case 'to-number': {
|
|
const v = resolveExpr(expr[1], props);
|
|
const n = Number(v);
|
|
if (Number.isFinite(n)) return n;
|
|
if (expr.length > 2) {
|
|
const fb = Number(resolveExpr(expr[2], props));
|
|
return Number.isFinite(fb) ? fb : 0;
|
|
}
|
|
return NaN;
|
|
}
|
|
case 'to-string': {
|
|
const v = resolveExpr(expr[1], props);
|
|
return v == null ? '' : String(v);
|
|
}
|
|
case 'to-boolean': return Boolean(resolveExpr(expr[1], props));
|
|
case 'slice': {
|
|
const s = resolveExpr(expr[1], props);
|
|
const i = resolveExpr(expr[2], props);
|
|
const j = expr.length > 3 ? resolveExpr(expr[3], props) : undefined;
|
|
if (typeof s !== 'string') return '';
|
|
return j != null ? s.slice(i, j) : s.slice(i);
|
|
}
|
|
default: return expr;
|
|
}
|
|
}
|
|
export function matchesExpression(filter, props) {
|
|
if (filter === true) return true;
|
|
if (filter === false) return false;
|
|
if (!Array.isArray(filter)) return Boolean(filter);
|
|
const op = filter[0];
|
|
if (op === 'all') {
|
|
for (let i = 1; i < filter.length; i++) {
|
|
if (!matchesExpression(filter[i], props)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
if (op === 'any') {
|
|
for (let i = 1; i < filter.length; i++) {
|
|
if (matchesExpression(filter[i], props)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (op === '!') return !matchesExpression(filter[1], props);
|
|
if (op === 'has') return props != null && filter[1] in props;
|
|
if (op === '!has') return props == null || !(filter[1] in props);
|
|
|
|
if (op === '==' || op === '!=' || op === '>' || op === '>=' ||
|
|
op === '<' || op === '<=') {
|
|
// Legacy form: ['==', 'propName', value] (no ['get', ...])
|
|
const left = (typeof filter[1] === 'string')
|
|
? props?.[filter[1]]
|
|
: resolveExpr(filter[1], props);
|
|
const right = resolveExpr(filter[2], props);
|
|
switch (op) {
|
|
case '==': return left === right || (left == null && right == null);
|
|
case '!=': return !(left === right || (left == null && right == null));
|
|
case '>': return left > right;
|
|
case '>=': return left >= right;
|
|
case '<': return left < right;
|
|
case '<=': return left <= right;
|
|
}
|
|
}
|
|
if (op === 'in') {
|
|
const v = resolveExpr(filter[1], props);
|
|
const haystack = resolveExpr(filter[2], props);
|
|
if (Array.isArray(haystack)) return haystack.includes(v);
|
|
if (typeof haystack === 'string') return haystack.includes(v);
|
|
return false;
|
|
}
|
|
if (op === 'match') {
|
|
const input = resolveExpr(filter[1], props);
|
|
const lastIdx = filter.length - 1;
|
|
for (let i = 2; i < lastIdx; i += 2) {
|
|
const labelRaw = filter[i];
|
|
const labels = Array.isArray(labelRaw) ? labelRaw : [labelRaw];
|
|
if (labels.includes(input)) return Boolean(resolveExpr(filter[i + 1], props));
|
|
}
|
|
return Boolean(resolveExpr(filter[lastIdx], props));
|
|
}
|
|
return false;
|
|
}
|
|
const _warnedUrlSources = new Set();
|
|
|
|
export function readSourceFeatures(map, sourceId) {
|
|
const src = map.getSource(sourceId);
|
|
if (!src) return [];
|
|
let data = src._data ?? src.serialize?.()?.data;
|
|
// ★ THE TWO ENGINES STORE _data DIFFERENTLY, and reading only Mapbox's shape is why this returned
|
|
// NOTHING on MapLibre. maplibre-gl's GeoJSONSource wraps it —
|
|
// this._data = typeof options.data === 'string' ? { url: options.data } : { geojson: options.data }
|
|
// — so `data.features` is undefined there and every caller silently got an empty list. Mapbox stores
|
|
// the GeoJSON directly, which is the engine this was written against, so it worked where it was
|
|
// tested and failed where it was not. Measured: rega's packet probe on MapLibre logged "0 pin(s)
|
|
// selected" for both a language-family and a prayer selection, with the pins plainly on screen.
|
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
if (data.geojson !== undefined) data = data.geojson;
|
|
else if (data.url !== undefined) {
|
|
// ★ A REMOTE SOURCE IS NOT AN EMPTY ONE, and saying so out loud is the whole point. The
|
|
// features live on a server; this cannot enumerate them. Returning [] silently is exactly
|
|
// the shape of the bug above — a caller would no-op and look merely unlucky. Warned ONCE
|
|
// per source so a map that legitimately streams its pins does not spam a console.
|
|
if (!_warnedUrlSources.has(sourceId)) {
|
|
_warnedUrlSources.add(sourceId);
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[pin-selection] source "${sourceId}" is remote (url); its features cannot be read locally, so nothing can be selected from it.`);
|
|
}
|
|
return [];
|
|
}
|
|
}
|
|
if (!data) return [];
|
|
if (Array.isArray(data?.features)) return data.features;
|
|
if (data?.type === 'Feature') return [data];
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* The ids of the pins a set of expressions currently selects, as a Set of strings.
|
|
*
|
|
* Every expression must match (they are ANDed) because they describe different halves of the same
|
|
* question: the layer's own filter says which pins RENDER, and a dim expression says which of those
|
|
* are lit. A pin at opacity 0 is not what the reader is looking at.
|
|
*
|
|
* With NO active expressions this returns every pin's id rather than an empty set — "nothing is
|
|
* selected" means everything is on screen, not nothing is.
|
|
*/
|
|
export function selectedPinIds(map, { sourceId = 'language-families', expressions = [], idKey = 'uniqueId' } = {}) {
|
|
const ids = new Set()
|
|
const exprs = (expressions || []).filter((e) => e != null && e !== true)
|
|
for (const f of readSourceFeatures(map, sourceId)) {
|
|
const props = f && f.properties
|
|
if (!props) continue
|
|
if (exprs.length && !exprs.every((e) => matchesExpression(e, props))) continue
|
|
const id = props[idKey]
|
|
if (id != null) ids.add(String(id))
|
|
}
|
|
return ids
|
|
}
|