From 891f7535d870342ec15435b0b3339fe410a52ab0 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 14:27:42 +0000 Subject: [PATCH] Update from source 53d386262 --- README.md | 20 ++ internal/check-describe-group.mjs | 117 ++++++++++ internal/check-list-selection.mjs | 130 +++++++++++ internal/check-pin-selection.mjs | 35 ++- internal/check-selector-fields.mjs | 164 ++++++++++++++ .../components/legends/ListSelectorLegend.vue | 92 ++++++++ library/components/legends/listSelection.js | 70 ++++++ .../legends/useLanguageFamilyLegendData.js | 8 +- library/composables/usePosterPrints.js | 9 +- library/maps/pin-selection.js | 65 ++++-- library/packets/describeGroup.js | 131 ++++++++++++ library/utils/languageLabel.js | 67 ++++++ package.json | 9 +- plugin-designs/big-picture/big-picture.vue | 13 +- .../doxa-research-map/research-map.vue | 17 +- .../my-upg-100-list/upg-100-list.vue | 20 +- .../dashboards/DashboardResearchFilters.vue | 9 +- public/dx-maps/big-picture/big-picture.js | 2 +- .../big-picture/big-picture/index.html | 2 +- .../big-picture/frontier/index.html | 2 +- .../big-picture/big-picture/index.html | 2 +- .../cities-internet/cities-internet.js | 24 +-- .../connectivity-list/index.html | 2 +- .../connectivity-map/index.html | 2 +- .../cities-internet/data-tables/index.html | 2 +- .../country-posters/countries-map/index.html | 2 +- .../country-poster-wall/index.html | 2 +- .../country-posters-mfe/index.html | 2 +- .../country-posters/country-posters.js | 202 +++++++++--------- .../dashboard-plus-grid.js | 42 ++-- .../dashboard-plus-grid/default/index.html | 2 +- .../dashboard-plus-grid/index.html | 2 +- public/dx-maps/doxa-maps-spa/manifest.json | 52 ++--- .../doxa-research-map/doxa-research-map.js | 156 +++++++------- .../doxa-research-map/research-map/index.html | 2 +- .../research-map/maplibre/index.html | 2 +- .../doxa-simple-map/doxa-simple-map.js | 34 +-- .../doxa-simple-map/adoption/index.html | 2 +- .../doxa-simple-map/engagement/index.html | 2 +- .../doxa-simple-map/index.html | 2 +- .../doxa-simple-map/prayer/index.html | 2 +- public/dx-maps/manifest.json | 52 ++--- .../my-upg-100-list/my-upg-100-list.js | 184 ++++++++-------- .../my-upg-100-list/poster-wall/index.html | 2 +- .../my-upg-100-list/upg-100-list/index.html | 2 +- .../my-upg-100-list/upg-100-mfe/index.html | 2 +- .../region-dashboards/index.html | 2 +- .../regions-dashboard/regions-dashboard.js | 178 +++++++-------- .../regions-dashboard/regions-list/index.html | 2 +- .../regions-dashboard/regions-mfe/index.html | 2 +- .../research-dashboard/research-dashboard.js | 52 ++--- .../research-dashboard/index.html | 2 +- 52 files changed, 1456 insertions(+), 546 deletions(-) create mode 100644 internal/check-describe-group.mjs create mode 100644 internal/check-list-selection.mjs create mode 100644 internal/check-selector-fields.mjs create mode 100644 library/components/legends/ListSelectorLegend.vue create mode 100644 library/components/legends/listSelection.js create mode 100644 library/packets/describeGroup.js create mode 100644 library/utils/languageLabel.js diff --git a/README.md b/README.md index 515a9c47..27f3fba8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,26 @@ Made by Vibe Coders for Vibe Coders to prepare the way of the Lord and make His **Isaiah 40:3 “...Prepare the way for the LORD in the wilderness; make a straight highway for our God in the desert."** +## Bottom line up front + +- **Why:** we believe the answer to the Great Commission is multiplying indigenous disciples, + churches and leaders among every remaining people group. That can't be done without maps, so + dx-maps gives the church map solutions to help finish world evangelization. +- **How it was made:** no human wrote this code. It is about 100% AI-coded, and about 90% of the + maps began as a vision spoken aloud (voice-to-text, such as Rudder) and handed to an AI agent that + built it with dx-maps. If you can picture a map and say it in plain English, an agent can build it. +- **Our favorite:** the experimental Gospel × Internet dashboard: the most experimental, and likely + the most impactful for the Great Commission, because everything comes together there. + It is inspired by the Perspectives course and book, and by Curtis Sergeant's 100-list and + relational-stewardship training (bounded and centered sets). +- **What each map is for:** + - **Big Picture:** the library's own interface, the one agents use to build maps. + - **UPG-100:** the most strategic. It divides the task into prayer packets. + - **Gospel maps:** the resources that exist in each language. + - **UPG Neighbor:** the reached neighbors near each unreached group. + - **Gospel × Internet:** all of it, together. Built for linguists, catalysts, translators (human + and AI) and prayer warriors. + Built with love, with the prayer that these maps bless the nations. # dx-maps — one build, maps that embed everywhere diff --git a/internal/check-describe-group.mjs b/internal/check-describe-group.mjs new file mode 100644 index 00000000..084ca1c0 --- /dev/null +++ b/internal/check-describe-group.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * check-describe-group — the packet's generated profile description, asserted per level, per missing + * field and per locale. + * + * WHY A TEST AND NOT A GLANCE: this string is generated for ~2,000 people groups in eleven languages + * and printed ~20 to a page. Nobody proofreads two thousand of them, so every failure mode here is + * silent — a missing field blanks a row, a wrong GSEC branch states the wrong fact about a real people, + * and an English fragment in an Arabic packet reads as a bug in the data rather than in the code. + * + * Headless, no network, no map. Strings are INJECTED, so this tests the function and not the catalogue. + * Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const { describeGroup } = await import(resolve(ROOT, 'library/packets/describeGroup.js')) + +// Two locales is enough to prove the lookup: one that exists, one that does not (falls back to en). +const S = { + en: { + desc_where_both: 'The {people} live in {country} and speak {language}.', + desc_where_country: 'The {people} live in {country}.', + desc_where_language: 'The {people} speak {language}.', + desc_gsec_0: 'There are no known evangelical Christians among them.', + desc_gsec_1: 'Fewer than 2% are evangelical, and no one has been planting churches.', + desc_gsec_4: 'Between 2% and 5% are evangelical.', + desc_gsec_6: 'More than 10% are evangelical.', + }, + ar: { + desc_where_both: 'يعيش {people} في {country} ويتحدثون {language}.', + desc_where_country: 'يعيش {people} في {country}.', + desc_gsec_1: 'أقل من 2% إنجيليون.', + }, +} +// The country resolver is INJECTED (see describeGroup's header: countryName.js is Vite-alias-only and +// imports JSON without an attribute, so importing it here would make this gate unrunnable). A two-entry +// stub proves the wiring and the localization; the ladder below proves what happens without it. +const CN = (iso, lc) => ({ MYS: { en: 'Malaysia', ar: 'ماليزيا' } }[iso] || {})[lc] || '' +const O = { strings: S, countryName: CN } +const G = { name: 'Abai Sungai', country_code: 'MYS', primary_language_label: 'Abai Sungai', imb_gsec: 1 } +const fails = [] +const eq = (what, got, want) => { if (got !== want) fails.push(`${what}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`) } +const has = (what, got, sub) => { if (!String(got).includes(sub)) fails.push(`${what}: ${JSON.stringify(sub)} missing from ${JSON.stringify(got)}`) } +const hasNot = (what, got, sub) => { if (String(got).includes(sub)) fails.push(`${what}: ${JSON.stringify(sub)} SHOULD NOT be in ${JSON.stringify(got)}`) } + +// 1. every GSEC level the catalogue defines resolves to ITS OWN sentence +for (const [g, frag] of [[0, 'no known evangelical'], [1, 'no one has been planting'], [4, 'Between 2% and 5%'], [6, 'More than 10%']]) { + has(`gsec ${g} picks its own level sentence`, describeGroup({ ...G, imb_gsec: g }, 'en', O), frag) +} +eq('gsec 1 in full', describeGroup(G, 'en', O), + 'The Abai Sungai live in Malaysia and speak Abai Sungai. Fewer than 2% are evangelical, and no one has been planting churches.') + +// 2. the country is resolved to the READER's language from its code, not left English +has('country localized from the code (ar)', describeGroup(G, 'ar', O), 'ماليزيا') +hasNot('and the English name is gone', describeGroup(G, 'ar', O), 'Malaysia') + +// 3. a missing clause is DROPPED, never blanked — the row still describes the people +const noLang = { ...G }; delete noLang.primary_language_label +eq('language missing -> country sentence only', describeGroup(noLang, 'en', O), + 'The Abai Sungai live in Malaysia. Fewer than 2% are evangelical, and no one has been planting churches.') +const noCountry = { ...G }; delete noCountry.country_code +eq('country missing -> language sentence only', describeGroup(noCountry, 'en', O), + 'The Abai Sungai speak Abai Sungai. Fewer than 2% are evangelical, and no one has been planting churches.') +has('level survives when both clauses are gone', describeGroup({ imb_gsec: 4 }, 'en', O), 'Between 2% and 5%') + +// 3b. THE LANGUAGE LABEL IS INDEX-ORDERED AND MUST BE UN-INVERTED BEFORE IT IS PRINTED. +// The API returns "Arabic, Shihhi"; the sentence has to read "Shihhi Arabic". 725 of the 12,379 rows +// carry an inverted label, so this is the single most-printed transformation in the description. +const shihhi = describeGroup({ ...G, name: 'Shihuh', primary_language_label: 'Arabic, Shihhi' }, 'en', O) +has('inverted label reads naturally', shihhi, 'speak Shihhi Arabic.') +hasNot('and the raw index ordering is NOT printed', shihhi, 'Arabic, Shihhi') +// A comma INSIDE a parenthetical is not an inversion marker — 13 real names would be shredded by a +// naive split ("Central Sulawesi) Boano (Indonesia"). These must pass through untouched. +has('parenthetical comma is left alone', describeGroup({ ...G, primary_language_label: 'Boano (Indonesia, Central Sulawesi)' }, 'en', O), 'speak Boano (Indonesia, Central Sulawesi).') +has('a trailing parenthetical stays at the end', describeGroup({ ...G, primary_language_label: 'Naga, Tangkhul (India)' }, 'en', O), 'speak Tangkhul Naga (India).') +has('an uninverted label is unchanged', describeGroup({ ...G, primary_language_label: 'Ainu (China)' }, 'en', O), 'speak Ainu (China).') + +// 3c. ★ THE FLATTENED CODE IS NEVER PRINTED. normalize.js flattens primary_language to its .value, an +// ISO 639-3 code; reading that field would print "and speak abf". A group carrying ONLY the code must +// fall to the country sentence — dropping the clause, which is the rule, rather than printing a code. +const codeOnly = { ...G }; delete codeOnly.primary_language_label; codeOnly.primary_language = 'abf' +eq('a bare language code is dropped, not printed', describeGroup(codeOnly, 'en', O), + 'The Abai Sungai live in Malaysia. Fewer than 2% are evangelical, and no one has been planting churches.') + +// 3d. NO RESOLVER: the clause degrades down countryName.js's own ladder instead of blanking. +eq('no resolver, no label -> the alpha-3 code, and the level still states its fact', describeGroup(G, 'ar', { strings: S }), + 'يعيش Abai Sungai في MYS ويتحدثون Abai Sungai. أقل من 2% إنجيليون.') +eq('no resolver, label present -> the label', describeGroup({ ...G, country_code_label: 'Malaysia' }, 'en', { strings: S }), + 'The Abai Sungai live in Malaysia and speak Abai Sungai. Fewer than 2% are evangelical, and no one has been planting churches.') +has('resolver returning nothing falls through to the code', describeGroup(G, 'en', { strings: S, countryName: () => '' }), 'live in MYS') + +// 4. missing or nonsense GSEC omits the level rather than inventing one or throwing +hasNot('no gsec -> no level sentence', describeGroup({ ...G, imb_gsec: undefined }, 'en', O), 'evangelical') +hasNot('gsec 9 is not a level', describeGroup({ ...G, imb_gsec: 9 }, 'en', O), 'evangelical') +eq('an empty group is an empty string, not a crash', describeGroup({}, 'en', O), '') +eq('a null group is an empty string', describeGroup(null, 'en', O), '') + +// 5. a locale the catalogue lacks falls back to English rather than printing the key +has('unknown locale falls back to en', describeGroup(G, 'zz', O), 'The Abai Sungai live in') +has('partial locale falls back PER KEY', describeGroup(G, 'ar', O), 'أقل من 2%') + +// 6. truncation cuts at a word boundary and only when asked +const long = describeGroup(G, 'en', O) +eq('no cap -> untouched', describeGroup(G, 'en', { ...O, maxChars: 0 }), long) +const cut = describeGroup(G, 'en', { ...O, maxChars: 40 }) +if (cut.length > 40) fails.push(`maxChars 40 honoured: got ${cut.length}`) +if (!cut.endsWith('…')) fails.push(`truncation is marked: ${JSON.stringify(cut)}`) +if (/\s$/.test(cut.slice(0, -1))) fails.push(`truncation trims its tail: ${JSON.stringify(cut)}`) + +if (fails.length) { + console.error('DESCRIBE-GROUP: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log('DESCRIBE-GROUP: PASS — every GSEC level states its own fact, an index-ordered language label is un-inverted without shredding a parenthetical, a missing clause is dropped rather than blanked, the country is localized from its code, and an unknown locale falls back per key.') diff --git a/internal/check-list-selection.mjs b/internal/check-list-selection.mjs new file mode 100644 index 00000000..beeaf9ab --- /dev/null +++ b/internal/check-list-selection.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * check-list-selection — what the vertical legend emits, and the zones a region tree forgets. Headless. + * + * ★ WHY THIS IS IN internal/ AND NOT internal-tracker/. It began as a internal-tracker probe, which is GITIGNORED + * in this repo (.gitignore:113) — so it ran green on my disk and would NOT have landed with the + * component. studio caught that while staging: "the 13 assertions are not in either commit". I had + * reported them as evidence for code that was about to ship untested, which is the same failure this + * whole file guards against — a green result measured somewhere other than where it matters. + * A probe proves something once. A registered check keeps proving it. + */ +// What the vertical legend emits, and the zones a region tree forgets. +// +// The second half is the one that matters most: four joinable UPG-100 zones are absent from +// zone-regions.json today, one of them the consumer's largest at 100 groups. A region-grouped tree +// drops them with no error and no empty state — just a shorter tree — so this asserts they survive. +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { leavesOf, zoneIdsIn, withUnassigned, selectedId, UNASSIGNED_ID } from '../library/components/legends/listSelection.js' +const DATA = join(dirname(fileURLToPath(import.meta.url)), '..', 'plugin-designs', 'my-upg-100-list', 'data') +const readJson = (f) => JSON.parse(readFileSync(join(DATA, f), 'utf8')) +let fails = 0 +const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ } + +const TREE = [{ + id: 'asia', label: 'Asia', depth: 0, + children: [ + { id: 'z1', label: 'Zone 1', zoneKey: 'Z1' }, + { id: 'sub', label: 'Sub', depth: 1, children: [{ id: 'z2' }, { id: 'z3' }] }, + ], +}] +// The real gap, named as it appears in the data. +const MISSING = [ + 'asia-china-guizhou-province', + 'asia-china-xinjiang-uyghur-autonomous-region', + 'asia-india-bihar', + 'asia-india-madhya-pradesh', +] + +ok(leavesOf(TREE[0]).map((l) => l.id).join(',') === 'z1,z2,z3', 'leavesOf walks NESTED children, not one level') +ok([...zoneIdsIn(TREE)].sort().join(',') === 'z1,z2,z3', 'zoneIdsIn collects every leaf id in the tree') + +// ── the emit contract ─────────────────────────────────────────────────────────────────────────── +ok(selectedId({ id: 'z1' }) === 'z1', 'a LEAF emits its zone id') +ok(selectedId({ id: 'asia' }) === 'asia', 'a REGION row emits the region id — the consumer expands it, where the join lives') +ok(selectedId(null) === null, 'and a CLEARED selection emits null, so a filter can be dropped') +ok(typeof selectedId({ id: 'z1' }) === 'string', + 'the value is a STRING, so it survives ?zone=, profile-config and a deep link') + +// ── never drop a zone ─────────────────────────────────────────────────────────────────────────── +const all = ['z1', 'z2', 'z3', ...MISSING] +const withBucket = withUnassigned(TREE, all, 'Unassigned') +ok(withBucket.length === TREE.length + 1, 'zones absent from the tree get an explicit bucket') +const bucket = withBucket[withBucket.length - 1] +ok(bucket.id === UNASSIGNED_ID && bucket.children.length === 4, 'and all four missing zones are in it') +ok(bucket.children.map((c) => c.id).includes('asia-china-xinjiang-uyghur-autonomous-region'), + 'including xinjiang — the consumer\'s largest zone at 100 groups, which a region tree loses silently') +ok(bucket.children.every((c) => c.id && c.zoneKey), + 'each is a SELECTABLE leaf, not a lump — a reader wants xinjiang, not "the four nobody filed"') +ok(withUnassigned(TREE, ['z1', 'z2', 'z3']).length === TREE.length, + 'CONTROL: no bucket appears when nothing is missing') +ok(withUnassigned(TREE, null).length === TREE.length && withUnassigned(TREE, []).length === TREE.length, + 'CONTROL: omitting the zone set leaves the tree untouched, so this is additive') + +// NEG CONTROL: the drop this exists to prevent must be detectable. +const naive = TREE // what a region-grouped tree gives you, unaugmented +ok(!zoneIdsIn(naive).has('asia-china-xinjiang-uyghur-autonomous-region'), + 'NEG CONTROL: the unaugmented tree really does omit xinjiang, so the bucket is not decoration') + +// ── a region sweep is NOT a zone sweep (gospel, END-GOS-VLG2) ─────────────────────────────────── +// ★ The consequence of the bucket that neither of us should leave implicit: ticking every REGION +// still does not select every ZONE, because the four unfiled zones have no region BY DEFINITION. +// Nothing on screen would say so — the reader just gets a smaller total, which is the exact +// silent-wrong-answer shape the bucket exists to end. So if a "select all" is ever added it must +// sweep ZONES, not regions, or it inherits the bug. Pinned here so the wrong one cannot ship +// quietly; gospel pins the mirror of it on the expansion side. +const regionRows = withBucket.filter((n) => n.id !== UNASSIGNED_ID) +const byRegionSweep = zoneIdsIn(regionRows) +ok(byRegionSweep.size < all.length, + 'selecting every REGION reaches fewer zones than exist — a region sweep is not a zone sweep') +ok(!byRegionSweep.has('asia-china-xinjiang-uyghur-autonomous-region'), + 'and the 100 groups it misses are xinjiang\'s, so a "select all" over regions must be over zones') +ok(zoneIdsIn(withBucket).size === all.length, + 'sweeping the WHOLE tree, bucket included, does reach all of them — what the bucket buys') + +// ── a zone with no members stays SELECTABLE (gospel: 16 of 33 resolve today) ──────────────────── +// Of the 33 zones, members resolve for 16; 5 await a wagf field and 9 have no selector anywhere. +// Those zones must render as EMPTY, not vanish — same reason as the bucket. A tree that hides what +// it cannot resolve tells the reader the zone does not exist, when the truth is that we cannot +// currently join it. Nothing here filters on member count, and this fails if anything starts to. +const EMPTYZONE = [{ id: 'asia', label: 'Asia', depth: 0, children: [{ id: 'z9', label: 'Zone 9', zoneKey: 'Z9', members: [] }] }] +ok(leavesOf(EMPTYZONE[0]).map((l) => l.id).join(',') === 'z9', + 'a zero-member zone is still a LEAF, so a reader can select it and see an empty result') +ok(selectedId(leavesOf(EMPTYZONE[0])[0]) === 'z9', 'and it still emits its own id') +ok(withUnassigned(EMPTYZONE, ['z9']).length === EMPTYZONE.length, + 'presence is by ID, not by member count, so an unresolvable zone is not ALSO dumped in the bucket') + +// ── the gap is REAL TODAY, not just real in my fixture (gospel, END-GOS-VLG3) ─────────────────── +// ★ EVERY ASSERTION ABOVE RUNS AGAINST A FIXTURE. That proves the MODULE behaves, and it proves the +// fixture models a gap — it does not prove the gap still exists in the data we ship. So the negative +// control above could stay green forever while the bucket quietly became dead code, or while a FIFTH +// zone went missing and nobody heard. That is the same failure in a new costume: evidence that lives +// somewhere other than where the thing it describes lives. gospel named it — find out where your +// evidence lives — after catching the identical shape in their own work. +// +// This does NOT resolve the three disagreeing lists (33 in zone-regions, 12 reachable via +// admin-to-list, 14 in groupings, disagreeing in BOTH directions). That is a data-ownership ruling +// for upg100 and the agent, and picking a winner here would be exactly the silent join we have both +// refused. It only compares the region tree's own source against the joinable set and RECORDS the +// difference, so the disagreement cannot drift without someone being told. +const regionOf = readJson('zone-regions.json') // the region tree's source: zone id -> region +const adminToList = readJson('admin-to-list.json') // admin name -> zone id; its VALUES are joinable +const filed = new Set(Object.keys(regionOf)) +const joinable = new Set(Object.values(adminToList).map(String)) +const realGap = [...joinable].filter((id) => !filed.has(id)).sort() + +ok(realGap.length > 0, + realGap.length > 0 + ? `the region tree really does omit ${realGap.length} joinable zone(s) TODAY — the bucket is load-bearing in the shipped data, not only in the fixture` + : 'INERT ARM: every joinable zone is now filed under a region, so this check no longer measures anything. That is not a pass. Re-derive the bucket against the current data or DELETE it and its component — do not leave a green arm guarding nothing.') +ok(realGap.join(',') === MISSING.join(','), + realGap.join(',') === MISSING.join(',') + ? 'and it is exactly the four this check names, so the fixture above models the shipped data rather than a story about it' + : `THE GAP MOVED — re-derive. shipped now: [${realGap.join(', ')}] but this check asserts: [${MISSING.join(', ')}]. A zone ADDED here is groups that just became unreachable; a zone REMOVED got filed and the fixture is stale. Either way the numbers in the commit body are no longer true.`) +ok(joinable.has('asia-china-xinjiang-uyghur-autonomous-region') && !filed.has('asia-china-xinjiang-uyghur-autonomous-region'), + 'xinjiang specifically: joinable in admin-to-list, absent from zone-regions — its 100 groups are reachable ONLY through the bucket') + +console.log(fails ? `\nLIST-SELECTION: ${fails} FAIL(s)` : '\nLIST-SELECTION: serialisable ids out, and no joinable zone can be dropped') +process.exit(fails ? 1 : 0) diff --git a/internal/check-pin-selection.mjs b/internal/check-pin-selection.mjs index 27a068ed..2985faec 100644 --- a/internal/check-pin-selection.mjs +++ b/internal/check-pin-selection.mjs @@ -78,9 +78,42 @@ for (const [label, src] of [['mapbox', { _data: COLLECTION }], ['maplibre', { _d } eq('expressions are ANDed', matchesExpression(['all', ['==', ['get', 'uniqueId'], 'a'], ['==', ['get', 'languageFamily'], 'Indo-European']], FEATURES[0].properties), false) +// ── PACKETSEL: the prayer count is LIVE, so the packet must read feature-state ────────────────── +// rega, RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend row said 699. +// The legend is right. Polling writes each pin's current count with setFeatureState() and +// prayer-progress.js:94 colours from ['feature-state','peoplePraying']; the GeoJSON property is +// whatever was true at page load. Reading the property selects the wrong people to pray for, which is +// the one thing this packet exists to get right. +const HAS_PRAYER = ['>', ['to-number', ['feature-state', 'peoplePraying'], 0], 0] +// 'a' has prayer only in the live state, 'b' only in the stale property, 'c' in neither. +const PRAY_FEATURES = [ + { type: 'Feature', properties: { uniqueId: 'a', peoplePraying: 0 }, geometry: { type: 'Point', coordinates: [1, 1] } }, + { type: 'Feature', properties: { uniqueId: 'b', peoplePraying: 12 }, geometry: { type: 'Point', coordinates: [2, 2] } }, + { type: 'Feature', properties: { uniqueId: 'c', peoplePraying: 0 }, geometry: { type: 'Point', coordinates: [3, 3] } }, +] +const STATE = { a: { peoplePraying: 7 } } +const prayMap = { + getSource: (id) => (id === 'pins' ? { _data: { type: 'FeatureCollection', features: PRAY_FEATURES } } : null), + getFeatureState: ({ id }) => STATE[id] || {}, +} +eq('feature-state selects the LIVE prayer count, not the loaded one', + [...selectedPinIds(prayMap, { sourceId: 'pins', expressions: [HAS_PRAYER] })].sort().join(','), 'a') +eq('the stale property alone does NOT select a pin', + selectedPinIds(prayMap, { sourceId: 'pins', expressions: [HAS_PRAYER] }).has('b'), false) +// A map with no feature-state at all (no polling yet) must still select on the loaded property rather +// than collapsing to nothing — a packet printed before the first poll is thinner, never empty. +const noState = { getSource: prayMap.getSource } +eq('no getFeatureState -> property expression still works', + [...selectedPinIds(noState, { sourceId: 'pins', expressions: [['>', ['to-number', ['get', 'peoplePraying'], 0], 0]] })].join(','), 'b') +eq('feature-state on a map that has none is undefined, not a throw', + matchesExpression(HAS_PRAYER, PRAY_FEATURES[1].properties, undefined), false) +eq('a source that throws on getFeatureState degrades instead of aborting the print', + selectedPinIds({ getSource: prayMap.getSource, getFeatureState: () => { throw new Error('no such source') } }, + { sourceId: 'pins', expressions: [HAS_PRAYER] }).size, 0) + if (fails.length) { console.error(`${TAG}: FAIL`) for (const f of fails) console.error(` ✗ ${f}`) process.exit(1) } -console.log(`${TAG}: PASS — both engine shapes read the same features, a remote source says so rather than looking empty, and selectedPinIds agrees with the reader.`) +console.log(`${TAG}: PASS — both engine shapes read the same features, a remote source says so rather than looking empty, selectedPinIds agrees with the reader, and the prayer count is read LIVE from feature-state rather than frozen at load.`) diff --git a/internal/check-selector-fields.mjs b/internal/check-selector-fields.mjs new file mode 100644 index 00000000..4b13bbc6 --- /dev/null +++ b/internal/check-selector-fields.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * check-selector-fields — every field a zone SELECTOR reads must be in every FETCH that evaluates it. + * + * THE DEFECT THIS CATCHES, measured 2026-09-28. The UPG-100 packet fetch requested + * slug,name,country_code,population,religion,image_url while SIX of the 27 printable zones select on + * wagf fields it never asked for (west-africa / east-africa / central-africa via wagf_block, europe via + * wagf_region, southern-africa-islands and oceania-indonesia via unions of them). evaluate() matched + * NOTHING, so those packets printed EMPTY — while the SAME selectors resolved correctly on the map, + * whose fetch had carried both fields all along. One file, two fetches, two field sets, nothing + * comparing them. + * + * ★ oceania-indonesia IS WHY THIS IS A GATE AND NOT A NOTE. Its union still matched country_code IDN, + * so it did not print empty — it printed a PLAUSIBLE packet silently missing every Oceania group. An + * empty packet is caught on page one; a packet quietly 40% short is not. Same asymmetry gospel measured + * the same day joining admin-to-list.json: 514 of 1009 rows matched and THREE whole zones vanished while + * every surviving zone still looked populated. A result that returns HALF looks like a smaller dataset. + * + * ★ FIELDS ARE READ FROM THE EVALUATOR, NOT FROM A TABLE IN THIS FILE. A hardcoded type→field map is a + * second source of truth and drifts silently the day a case is edited. This parses selector-evaluator.js + * and collects the `r.` reads inside each `case '':` block, so a new field in a case is + * picked up without touching this gate. It is also how the non-obvious ones are caught: deaf_predicate + * reads wagf_region AND slug AND name; affinity_bloc reads rop1. Neither is guessable from the name. + * + * ⚠ LIMIT, stated so a green run is not over-read: it matches `const fields = '…'` string literals. A + * fetch that builds its field list dynamically is NOT checked, and is reported as UNCHECKED rather than + * passed — absence of evidence is not evidence. + * + * --self-test plants a wagf_block selector against a fetch missing the field. Exit 0 clean · 1 findings. + */ +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { join, relative, resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +// ── the evaluator's own field reads, per selector type ────────────────────────────────────────── +export function fieldsByType(evaluatorSrc) { + const out = new Map() + // Split on `case '':` and read each block up to the next case. + const re = /case\s+'([a-z_]+)'\s*:\s*\{/g + const marks = [] + let m + while ((m = re.exec(evaluatorSrc))) marks.push({ type: m[1], at: m.index }) + for (let i = 0; i < marks.length; i++) { + const body = evaluatorSrc.slice(marks[i].at, i + 1 < marks.length ? marks[i + 1].at : evaluatorSrc.length) + const fields = new Set() + for (const f of body.matchAll(/\br\.([a-zA-Z_][\w]*)/g)) fields.add(f[1]) + out.set(marks[i].type, fields) + } + return out +} + +/** Every field a selector tree needs, following union/nested selectors. */ +export function fieldsForSelector(sel, byType, acc = new Set()) { + if (!sel || typeof sel !== 'object') return acc + const t = sel.type + if (t && byType.has(t)) for (const f of byType.get(t)) acc.add(f) + for (const child of sel.selectors || []) fieldsForSelector(child, byType, acc) + return acc +} + +/** `const fields = 'a,b,c'` / `const _fields = '…'` occurrences, with their line. */ +export function fetchFieldLists(src) { + const out = [] + for (const m of src.matchAll(/const\s+_?fields\s*=\s*'([^']*)'/g)) { + out.push({ line: src.slice(0, m.index).split('\n').length, fields: new Set(m[1].split(',').map((s) => s.trim()).filter(Boolean)) }) + } + // A dynamically-built list is reported, never silently passed. ⚠ The lookahead must reject a + // literal that is merely INDENTED differently — an earlier version matched the same + // lines it had already parsed and reported them as dynamic, which would have + // trained a reader to ignore the ⊘ marker. Anchor on the assignment actually not being a quote. + for (const m of src.matchAll(/const\s+_?fields\s*=\s*([^'\s])/g)) { + out.push({ line: src.slice(0, m.index).split('\n').length, dynamic: true }) + } + return out +} + +// ── SELF-TEST ─────────────────────────────────────────────────────────────────────────────────── +const EVAL_FIXTURE = ` + switch (selector.type) { + case 'country_code': { for (const r of rows) { if (r.country_code === want) {} } } + case 'wagf_block': { for (const r of rows) { if (r.wagf_block === want) {} } } + case 'deaf_predicate': { for (const r of rows) { if (containsDeaf(r.slug) || containsDeaf(r.name)) { if (regions.has(r.wagf_region)) {} } } } + }` +const CASES = [ + ['reads wagf_block from the evaluator', () => fieldsByType(EVAL_FIXTURE).get('wagf_block').has('wagf_block'), true], + ['deaf_predicate reads 3 fields, not 1', () => fieldsByType(EVAL_FIXTURE).get('deaf_predicate').size, 3], + ['union follows its children', () => { + const t = fieldsByType(EVAL_FIXTURE) + return [...fieldsForSelector({ type: 'union', selectors: [{ type: 'wagf_block' }, { type: 'country_code' }] }, t)].sort().join(',') + }, 'country_code,wagf_block'], + ['BAD a fetch missing wagf_block is caught', () => { + const t = fieldsByType(EVAL_FIXTURE) + const need = fieldsForSelector({ type: 'wagf_block' }, t) + const have = fetchFieldLists("const fields = 'slug,name,country_code';")[0].fields + return [...need].filter((f) => !have.has(f)).join(',') + }, 'wagf_block'], + ['GOOD a fetch carrying it passes', () => { + const t = fieldsByType(EVAL_FIXTURE) + const need = fieldsForSelector({ type: 'wagf_block' }, t) + const have = fetchFieldLists("const fields = 'slug,wagf_block';")[0].fields + return [...need].filter((f) => !have.has(f)).length + }, 0], + ['a DYNAMIC field list is flagged, not passed', () => fetchFieldLists('const fields = buildFields();')[0].dynamic, true], +] + +if (process.argv.includes('--self-test')) { + let ok = 0 + for (const [name, run, want] of CASES) { + let got + try { got = run() } catch (e) { got = `ERR ${e.message}` } + const pass = got === want + console.log(` ${pass ? '✓' : '✗'} ${name} -> ${JSON.stringify(got)} (want ${JSON.stringify(want)})`) + if (pass) ok++ + } + console.log(ok === CASES.length + ? `SELECTOR-FIELDS SELF-TEST: PASS — ${ok}/${CASES.length}` + : `SELECTOR-FIELDS SELF-TEST: FAIL — ${ok}/${CASES.length}`) + process.exit(ok === CASES.length ? 0 : 1) +} + +// ── RUN ───────────────────────────────────────────────────────────────────────────────────────── +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const DESIGNS = join(ROOT, 'plugin-designs') +let bad = 0, unchecked = 0 + +for (const bundle of existsSync(DESIGNS) ? readdirSync(DESIGNS) : []) { + const dir = join(DESIGNS, bundle) + if (!statSync(dir).isDirectory()) continue + const evalPath = join(dir, 'data/selector-evaluator.js') + const clustersPath = join(dir, 'data/clusters.json') + if (!existsSync(evalPath) || !existsSync(clustersPath)) continue + + const byType = fieldsByType(readFileSync(evalPath, 'utf8')) + const c = JSON.parse(readFileSync(clustersPath, 'utf8')) + const zones = ['lists', 'deaf_lists', 'asia_lists'].flatMap((k) => c[k] || []) + + const need = new Map() // field -> the zones that need it + for (const z of zones) for (const f of fieldsForSelector(z.selector, byType)) { + if (!need.has(f)) need.set(f, []) + need.get(f).push(z.id) + } + + // Every .vue in the bundle that both fetches AND evaluates. + for (const file of readdirSync(dir).filter((f) => f.endsWith('.vue'))) { + const src = readFileSync(join(dir, file), 'utf8') + if (!/selector-evaluator|evaluate\(/.test(src)) continue + for (const fetchDef of fetchFieldLists(src)) { + const where = `${relative(ROOT, join(dir, file))}:${fetchDef.line}` + if (fetchDef.dynamic) { console.log(` ⊘ ${where} field list is built dynamically — UNCHECKED, not passed`); unchecked++; continue } + const missing = [...need.keys()].filter((f) => !fetchDef.fields.has(f)) + for (const f of missing) { + console.log(` ✗ ${where} does not request '${f}', needed by: ${need.get(f).slice(0, 4).join(', ')}${need.get(f).length > 4 ? ` (+${need.get(f).length - 4})` : ''}`) + bad++ + } + } + } +} + +if (bad) { + console.log(`SELECTOR-FIELDS: FAIL — ${bad} fetch/selector mismatch(es). A zone whose selector field is absent resolves to NOBODY, or worse, to HALF.`) + process.exit(1) +} +console.log(`SELECTOR-FIELDS: PASS — every selector's fields are requested by every fetch that evaluates them${unchecked ? `, ${unchecked} dynamic list(s) unchecked` : ''}.`) +process.exit(0) diff --git a/library/components/legends/ListSelectorLegend.vue b/library/components/legends/ListSelectorLegend.vue new file mode 100644 index 00000000..2a869e50 --- /dev/null +++ b/library/components/legends/ListSelectorLegend.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/library/components/legends/listSelection.js b/library/components/legends/listSelection.js new file mode 100644 index 00000000..14a79e79 --- /dev/null +++ b/library/components/legends/listSelection.js @@ -0,0 +1,70 @@ +/** + * listSelection.js — what a vertical-legend row selects, and the zones a tree forgot. + * + * Extracted from ListSelectorLegend.vue so both rules can be tested without mounting a component. + * Small rules, large consequences: a dashboard filters thousands of rows by the first, and the + * second decides whether 100+ groups are reachable at all. + */ + +/** Every leaf under a node, or the node itself when it is a leaf. */ +export function leavesOf(node) { + if (!node) return [] + const kids = node.children || node.nodes || [] + if (!kids.length) return [node] + const out = [] + for (const k of kids) out.push(...leavesOf(k)) + return out +} + +/** Every zone id present anywhere in a tree. */ +export function zoneIdsIn(nodes) { + const out = new Set() + for (const n of nodes || []) for (const l of leavesOf(n)) if (l && l.id) out.add(String(l.id)) + return out +} + +export const UNASSIGNED_ID = '__unassigned__' + +/** + * ★ A ZONE THE TREE DOES NOT CONTAIN MUST STILL BE REACHABLE. Four joinable zones are absent from + * zone-regions.json today — guizhou, xinjiang (100+ groups on its own), bihar, madhya-pradesh — so a + * legend that renders only the region tree DROPS them, silently, and a reader has no way to select + * groups that plainly exist. The fix is not to remember: it is to hand this function the full zone + * set and let it render the difference. A drop then becomes structurally impossible rather than a + * thing someone has to keep checking. + * + * Returns the tree unchanged when nothing is missing, so the bucket never appears without cause. + * + * @param {Array} nodes the region-grouped tree + * @param {Array} allZoneIds every zone the data can join, tree or no tree + * @param {string} label what to call the bucket (the caller localises it) + */ +export function withUnassigned(nodes, allZoneIds, label = 'Unassigned') { + const present = zoneIdsIn(nodes) + const missing = (allZoneIds || []).map(String).filter((id) => !present.has(id)) + if (!missing.length) return nodes || [] + return [ + ...(nodes || []), + { + id: UNASSIGNED_ID, + label, + depth: 0, + // Each missing zone is a selectable leaf, not a lump: a reader wants xinjiang, not "the four + // zones nobody filed". + children: missing.map((id) => ({ id, label: id, zoneKey: id })), + }, + ] +} + +/** + * The value the legend emits. gospel's contract: a single SERIALISABLE id, so it survives ?zone=, + * profile-config and a deep link, and stays usable across their four tab shapes. + * + * A leaf emits its zone id; a REGION row emits the region's own id (dx-shepherd: a region selects + * the union of its zones). The consumer expands a region id to its zones using the zone data it + * already holds — which is also where the diacritic-folding join lives, so there is no second copy + * of it here to drift. + */ +export function selectedId(node) { + return node && node.id != null ? String(node.id) : null +} diff --git a/library/composables/legends/useLanguageFamilyLegendData.js b/library/composables/legends/useLanguageFamilyLegendData.js index e75a67b0..80f0c8ad 100644 --- a/library/composables/legends/useLanguageFamilyLegendData.js +++ b/library/composables/legends/useLanguageFamilyLegendData.js @@ -44,6 +44,7 @@ import { computed, reactive } from 'vue' import { getLanguageFamilyColor, LANGUAGE_FAMILY_COLORS, canonicalFamilyName } from '../../colors/language-family.js' import langFamilyByLanguage from '../../data/langFamilyByLanguage.json' +import { splitLanguageLabelParts } from '../../utils/languageLabel.js' // ───────────────────────────────────────────────────────────────────────────── // Field readers — defensive against the four shapes we currently see in the @@ -64,9 +65,14 @@ function unwrap(v) { // "Arabic, Shihhi" instead of the lookup key "Shihhi Arabic". // Strategy: reverse all comma-separated parts, join with space, then look up. // For parenthetical names like "Ainu (China)", also try stripping the parenthetical. +// +// The split is shared with the packet's describeGroup, which needs the same un-inversion for PRINTED +// prose: see library/utils/languageLabel.js for why a top-level-only split is the correct one. The +// candidates below are unchanged -- a parenthesis-internal comma used to yield an unbalanced candidate +// that could never match a key, and now yields no candidate at all, which is the same miss. function lookupFamilyFromLanguageLabel(label) { if (!label || typeof label !== 'string') return null - const parts = label.split(',').map(s => s.trim()).filter(Boolean) + const parts = splitLanguageLabelParts(label) if (parts.length >= 2) { // Try full reversal: "Arabic, Shihhi" → "Shihhi Arabic" const fullReversed = [...parts].reverse().join(' ') diff --git a/library/composables/usePosterPrints.js b/library/composables/usePosterPrints.js index b6a8162c..385403d9 100644 --- a/library/composables/usePosterPrints.js +++ b/library/composables/usePosterPrints.js @@ -233,9 +233,14 @@ export function usePosterPrints(opts = {}) { set(item, kind, 'fetch', 'fetching people groups', 30); try { const data = await rows(item); - const [{ createPacketRenderer }, strings] = await Promise.all([ + // `christian` is the flock-wide evangelical share behind the packet's %-evangelical page. The + // Node generator passes it (packet.mjs reads data/people-groups/christian-share.json); this + // path did not, so buildPacket's christianPage returned '' and a browser print had one sheet + // fewer with nothing to say so. Same seam as strings: a host may supply its own figure. + const [{ createPacketRenderer }, strings, christian] = await Promise.all([ import('../packet/packet-render.js'), opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m), + opts.christian ? opts.christian() : import('../../data/people-groups/christian-share.json').then((m) => m.default || m), ]); const R = createPacketRenderer({ strings, lang: lang() }); // Photos stay as URLs. The Node generator base64-inlines them so its file is @@ -245,7 +250,7 @@ export function usePosterPrints(opts = {}) { data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]), ); set(item, kind, 'render', `printing ${data.length} people groups`, 70); - openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel), { autoprint: true }); + openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel, christian), { autoprint: true }); done(item, kind); } catch (e) { fail(item, kind, e); } } diff --git a/library/maps/pin-selection.js b/library/maps/pin-selection.js index 3d6a5f18..bbc85351 100644 --- a/library/maps/pin-selection.js +++ b/library/maps/pin-selection.js @@ -19,7 +19,7 @@ * 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: + * in/match, plus get/literal/feature-state/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. */ @@ -28,65 +28,72 @@ // 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) { +export function resolveExpr(expr, props, state) { if (!Array.isArray(expr)) return expr; const op = expr[0]; switch (op) { case 'get': return props?.[expr[1]]; case 'literal': return expr[1]; + // ★ THE LIVE VALUE, NOT THE ONE FROZEN AT LOAD. A pin's prayer count arrives twice: as a GeoJSON + // property at initial load, and thereafter through setFeatureState() as polling updates it. + // prayer-progress.js:94 colours from ['feature-state','peoplePraying'] once polling is on, so a + // reader looking at 699 lit "Has Prayer" pins is looking at feature-state. Without this case the + // expression resolved to undefined, to-number defaulted it to 0, and the packet selected only + // the pins whose FROZEN property happened to be > 0 — 50 of 997 where the legend said 699. + case 'feature-state': return state == null ? undefined : state[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 '!': return !resolveExpr(expr[1], props, state); case 'coalesce': { for (let i = 1; i < expr.length; i++) { - const v = resolveExpr(expr[i], props); + const v = resolveExpr(expr[i], props, state); if (v != null) return v; } return null; } case 'to-number': { - const v = resolveExpr(expr[1], props); + const v = resolveExpr(expr[1], props, state); const n = Number(v); if (Number.isFinite(n)) return n; if (expr.length > 2) { - const fb = Number(resolveExpr(expr[2], props)); + const fb = Number(resolveExpr(expr[2], props, state)); return Number.isFinite(fb) ? fb : 0; } return NaN; } case 'to-string': { - const v = resolveExpr(expr[1], props); + const v = resolveExpr(expr[1], props, state); return v == null ? '' : String(v); } - case 'to-boolean': return Boolean(resolveExpr(expr[1], props)); + case 'to-boolean': return Boolean(resolveExpr(expr[1], props, state)); case 'slice': { - const s = resolveExpr(expr[1], props); - const i = resolveExpr(expr[2], props); - const j = expr.length > 3 ? resolveExpr(expr[3], props) : undefined; + const s = resolveExpr(expr[1], props, state); + const i = resolveExpr(expr[2], props, state); + const j = expr.length > 3 ? resolveExpr(expr[3], props, state) : undefined; if (typeof s !== 'string') return ''; return j != null ? s.slice(i, j) : s.slice(i); } default: return expr; } } -export function matchesExpression(filter, props) { +export function matchesExpression(filter, props, state) { 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; + if (!matchesExpression(filter[i], props, state)) return false; } return true; } if (op === 'any') { for (let i = 1; i < filter.length; i++) { - if (matchesExpression(filter[i], props)) return true; + if (matchesExpression(filter[i], props, state)) return true; } return false; } - if (op === '!') return !matchesExpression(filter[1], props); + if (op === '!') return !matchesExpression(filter[1], props, state); if (op === 'has') return props != null && filter[1] in props; if (op === '!has') return props == null || !(filter[1] in props); @@ -95,8 +102,8 @@ export function matchesExpression(filter, props) { // 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); + : resolveExpr(filter[1], props, state); + const right = resolveExpr(filter[2], props, state); switch (op) { case '==': return left === right || (left == null && right == null); case '!=': return !(left === right || (left == null && right == null)); @@ -107,21 +114,21 @@ export function matchesExpression(filter, props) { } } if (op === 'in') { - const v = resolveExpr(filter[1], props); - const haystack = resolveExpr(filter[2], props); + const v = resolveExpr(filter[1], props, state); + const haystack = resolveExpr(filter[2], props, state); 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 input = resolveExpr(filter[1], props, state); 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)); + if (labels.includes(input)) return Boolean(resolveExpr(filter[i + 1], props, state)); } - return Boolean(resolveExpr(filter[lastIdx], props)); + return Boolean(resolveExpr(filter[lastIdx], props, state)); } return false; } @@ -172,11 +179,23 @@ export function readSourceFeatures(map, sourceId) { export function selectedPinIds(map, { sourceId = 'language-families', expressions = [], idKey = 'uniqueId' } = {}) { const ids = new Set() const exprs = (expressions || []).filter((e) => e != null && e !== true) + // ★ FEATURE-STATE IS READ PER PIN, and it has to come off the MAP rather than off the feature: the + // GeoJSON the source holds is what was loaded, and setFeatureState writes to a separate per-feature + // store that never touches it. useMapLayers adds this source with promoteId:'uniqueId', so the id + // feature-state is keyed by IS props[idKey] -- if a caller ever passes a different idKey the lookup + // yields {} and the evaluation falls back to the loaded properties, which is the old behaviour and + // not a new failure. getFeatureState throws on an unknown source, so it is guarded once, not per pin. + const canReadState = typeof map?.getFeatureState === 'function' + const stateOf = (id) => { + if (!canReadState || id == null) return undefined + try { return map.getFeatureState({ source: sourceId, id }) } catch (_) { return undefined } + } 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] + const state = stateOf(id) + if (exprs.length && !exprs.every((e) => matchesExpression(e, props, state))) continue if (id != null) ids.add(String(id)) } return ids diff --git a/library/packets/describeGroup.js b/library/packets/describeGroup.js new file mode 100644 index 00000000..b9727855 --- /dev/null +++ b/library/packets/describeGroup.js @@ -0,0 +1,131 @@ +/** + * describeGroup.js — one localized sentence pair describing a people group, built ONLY from API fields. + * + * DRIVER BRIEF 2026-09-28 §A1.5: the packet's profile rows carry "a short GENERATED description built + * from the API variables, localized: how lost/unreached they are, where (country), what language they + * speak". ~20 rows per page, so it has to be short, and it has to be right in eleven languages. + * + * PURE. (group, locale) -> string. No refs, no map, no fetch. The strings object is the third argument + * so a test can inject one; it defaults to the packet's own catalogue, which is what the renderer + * already loads (usePosterPrints.js:209). + * + * ★ THE LEVEL IS PHRASED FROM GSEC, A NUMBER — never from EvangelicalLevel or GSECDescription, which are + * ENGLISH PROSE the API and the CSV hand us ("2% or Greater but Less than 5%"). Interpolating either + * leaves the widest column of every non-English packet in English, which is the leak class this repo + * spent 2026-09-28 removing. GSEC is 0-6 and present on all 12,379 rows, so it maps to a key per level. + * + * ★ AND IT DOES NOT SAY "unreached" OR "frontier". The packet's funnel page already defines both + * (packet-strings funnel_unreached "under 2% evangelical", funnel_frontier "0.1% or less"). GSEC carries + * the 2% line — levels 0-3 are exactly "fewer than 2%", 7,307 of 12,379 rows — but NOTHING in GSEC + * encodes 0.1%: no GSECDescription or GSECLongDescription mentions it (checked by upg100 and by me, + * independently). Deriving "frontier" from GSEC would assert something the field does not carry, and + * §E of the same brief has the UPG/FPG calculation open. So the wording agrees with the funnel's 2% + * line and stops there; when E lands it is one more key, not a rewrite. + * + * ★ TWO SENTENCES, NOT ONE, AND THAT IS WHY THE FALLBACKS WORK. upg100 authored seven whole sentences + * whose FIRST half is identical in all seven, deliberately, so a translator writes it once. Splitting + * that shared half into its own key makes the saving real instead of aspirational, and it is the only + * shape in which their own fallback rule can be expressed: drop the clause, never blank the row. Their + * English is preserved verbatim; only the seam moved. Each key is a COMPLETE sentence — the thing rule 1 + * forbids is assembling one sentence from fragments, which is how a Hindi verb ends up in the wrong + * place; two finished sentences in sequence carry no such risk. + * + * KEYS (packet-strings.json, beside funnel_*): + * desc_where_both "The {people} live in {country} and speak {language}." + * desc_where_country "The {people} live in {country}." — language unknown (1 row of 12,379) + * desc_where_language "The {people} speak {language}." — country unknown + * desc_gsec_0 … _6 the level sentence, one per GSEC value + */ +import { naturalLanguageLabel } from '../utils/languageLabel.js' + +const LEVELS = [0, 1, 2, 3, 4, 5, 6] + +/** The first field present, trimmed; '' when none is. Accepts both the API and normalized spellings. */ +const pick = (group, ...names) => { + for (const n of names) { + const v = group && group[n] + if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim() + } + return '' +} + +const fill = (tpl, params) => + String(tpl).replace(/\{(\w+)\}/g, (m, k) => (params[k] !== undefined ? params[k] : m)) + +/** + * Cut at a WORD boundary and mark it. Physical width is the real budget (the profile column is ~1/5 of + * a card in packet-render's 2x10 grid), so the caller measures and decides; this only guarantees that a + * limit, when given, is honoured without slicing a word in half. No limit by default. + */ +const clip = (s, max) => { + if (!max || s.length <= max) return s + const cut = s.slice(0, max - 1) + const sp = cut.lastIndexOf(' ') + return (sp > max * 0.6 ? cut.slice(0, sp) : cut).trimEnd() + '…' +} + +/** + * @param {Object} group an API/normalized people-group row + * @param {string} locale e.g. 'ar'; falls back to 'en' when the catalogue has no such block + * @param {Object} [opts] + * @param {Object} [opts.strings] the packet strings object ({ en: {...}, ar: {...} }) + * @param {function} [opts.countryName] (iso3, locale) => localized country name; see below for why + * this is injected and what happens when it is not + * @param {number} [opts.maxChars] optional hard cap, cut at a word boundary + * @returns {string} '' when the group carries none of the three facts + */ +export function describeGroup(group, locale, opts = {}) { + const strings = opts.strings || {} + const lc = String(locale || 'en').split('-')[0].toLowerCase() + const block = strings[lc] || strings.en || {} + const en = strings.en || {} + const key = (k) => block[k] || en[k] || '' + + const people = pick(group, 'name', 'Name', 'peopleName') + // The country is resolved to the READER's language from its code — the packet fetch carries + // country_code, and country_code_label is the API's ENGLISH name, which is a leak in a Hindi packet. + // + // ★ THE RESOLVER IS INJECTED RATHER THAN IMPORTED, and that is a testability decision, not a style + // one. library/utils/countryName.js is reachable only through the Vite '@map/' alias and imports two + // JSON files without an import attribute — both fine in a bundle, both fatal to `node internal/ + // check-describe-group.mjs`, which is the gate that proves this function right in eleven languages. + // Importing it would make this module unloadable by its own test. So the packet renderer, which runs + // inside Vite and can import it, passes it in: opts.countryName = (iso, locale) => string. + // + // ★ AND THE FALLBACK IS THE SAME LADDER countryName.js WALKS, so forgetting to inject degrades the + // way that file already documents rather than blanking the clause: localized name → the canonical + // English name we already show in our own tables → the alpha-3 code, so something identifiable + // always renders. A missing injection is an English country name, never an empty row. + const iso = pick(group, 'country_code', 'countryIso', 'ISOAlpha3', 'countryCode') + const country = (iso && opts.countryName ? opts.countryName(iso, lc) : '') + || pick(group, 'country_code_label', 'country', 'CountryName', 'countryName') + || iso + // A language NAME is a proper noun; it is not translated, and there is no source that would. + // + // ★ IT IS primary_language_LABEL, NOT primary_language. primary_language is in normalize.js's + // NESTED_FIELDS, so the API's { value, label } is flattened to its VALUE -- an ISO 639-3 code. Reading + // it would print "and speak abf". The label is the name. There is deliberately no fall-back to the + // code: upg100's rule for a missing field is DROP THE CLAUSE, never blank the row, and a bare code is + // worse for a reader than no clause at all -- so an unresolvable language falls to desc_where_country. + // + // ★ AND THE LABEL IS INDEX-ORDERED: the API hands us "Arabic, Shihhi" for what reads "Shihhi Arabic". + // 725 of 12,379 rows carry one. naturalLanguageLabel un-inverts it, sharing its split with the + // language-family legend so the two readings of the same field cannot drift apart. + const language = naturalLanguageLabel( + pick(group, 'primary_language_label', 'LanguageName', 'languageName'), + ) + + const whereKey = language && country ? 'desc_where_both' + : country ? 'desc_where_country' + : language ? 'desc_where_language' : '' + const where = whereKey && people ? fill(key(whereKey), { people, country, language }) : '' + + // imb_gsec is the API's spelling (upg100 added it to the packet fetch); GSEC is the CSV's. + const gsecRaw = pick(group, 'imb_gsec', 'gsec', 'GSEC') + const gsec = gsecRaw === '' ? null : Number(gsecRaw) + const level = LEVELS.includes(gsec) ? key(`desc_gsec_${gsec}`) : '' + + return clip([where, level].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(), opts.maxChars) +} + +export default describeGroup diff --git a/library/utils/languageLabel.js b/library/utils/languageLabel.js new file mode 100644 index 00000000..a9d8a6b1 --- /dev/null +++ b/library/utils/languageLabel.js @@ -0,0 +1,67 @@ +/** + * languageLabel.js — turn the API's comma-inverted language label into the natural reading. + * + * The pray-tools API returns primary_language.label in an INDEX ordering, surname-first: + * "Arabic, Shihhi" where a reader expects "Shihhi Arabic". It is the ordering a printed language + * catalogue uses so that every Arabic variety files together, and it is wrong in a sentence. + * 725 of the 12,379 rows carry one (measured on data/people-groups/people_groups.csv, LanguageName, + * the same vocabulary), so this is not an edge case: it is one row in seventeen. + * + * ★ THE SPLIT IS PARENTHESIS-AWARE, AND THAT IS THE WHOLE REASON THIS IS A MODULE. 13 names carry a + * comma INSIDE a parenthetical -- "Boano (Indonesia, Central Sulawesi)", "Kango (DRC, Tshopo district)", + * "Mari (PNG, Madang Province)". A naive label.split(',') reverses those into + * "Central Sulawesi) Boano (Indonesia": unbalanced, unreadable, and printed. The comma in those names + * is not an inversion marker, it separates a disambiguating region, so only a TOP-LEVEL comma counts. + * + * ★ THIS IS HARMLESS IN A LOOKUP AND FATAL IN A SENTENCE, which is why the rule lives here rather than + * staying where it was. useLanguageFamilyLegendData built the same reversal to produce a KEY for + * langFamilyByLanguage, where a garbage candidate simply misses and the next one is tried -- the + * corruption never reaches a reader. describeGroup interpolates the result into printed prose, where + * there is no such safety net. Both now split through splitLanguageLabelParts so the two cannot drift. + */ + +/** + * Split a label on its TOP-LEVEL commas — commas inside parentheses are text, not separators. + * "Arabic, Shihhi" -> ['Arabic', 'Shihhi']; "Boano (Indonesia, Central Sulawesi)" -> one part. + * @param {string} label + * @returns {string[]} trimmed, empty parts dropped ("Kango (DRC, , Bas-Uélé district)" is real data) + */ +export function splitLanguageLabelParts(label) { + if (!label || typeof label !== 'string') return [] + const parts = [] + let depth = 0 + let cur = '' + for (const ch of label) { + if (ch === '(') depth++ + else if (ch === ')') depth = Math.max(0, depth - 1) + if (ch === ',' && depth === 0) { + parts.push(cur) + cur = '' + } else { + cur += ch + } + } + parts.push(cur) + return parts.map((s) => s.trim()).filter(Boolean) +} + +/** + * The natural reading of a language label, for display. + * Un-inverted when the label is inverted; returned unchanged when it is not, so it is safe to call on + * any label from any source. A trailing parenthetical stays at the END, where it qualifies the whole + * name: "Naga, Tangkhul (India)" -> "Tangkhul Naga (India)", not "Tangkhul (India) Naga". + * @param {string} label + * @returns {string} '' for a missing or non-string label + */ +export function naturalLanguageLabel(label) { + if (!label || typeof label !== 'string') return '' + const parts = splitLanguageLabelParts(label) + if (parts.length < 2) return label.trim() + const last = parts[parts.length - 1] + const paren = last.match(/\s*(\(.*\))$/) + if (paren) parts[parts.length - 1] = last.slice(0, paren.index).trim() + const reversed = parts.filter(Boolean).reverse().join(' ') + return paren ? `${reversed} ${paren[1]}` : reversed +} + +export default naturalLanguageLabel diff --git a/package.json b/package.json index 67345804..6679dba3 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "check:dashboard-geocoder-provider": "node plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs", "check:deck-click": "node internal/check-deck-click.mjs", "check:dep-names": "node internal/check-dependency-names.mjs", + "check:describe-group": "node internal/check-describe-group.mjs", "check:descriptions": "node internal/check-profile-descriptions.mjs", "check:dev-assets": "node internal/check-dev-assets.mjs", "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs", @@ -140,6 +141,7 @@ "check:link-rot": "node internal/check-link-rot-sample.mjs", "check:linked-vocabulary": "node plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs", "check:list-prop-unit": "node internal/check-list-prop-unit.mjs", + "check:list-selection": "node internal/check-list-selection.mjs", "check:locale-reachable": "node internal/check-locale-reachable.mjs", "check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs", "check:map-data-painted": "node internal/check-map-data-painted.mjs", @@ -182,6 +184,8 @@ "check:packet-render": "node internal/check-packet-render.mjs", "check:packet-render:expect-funnel": "node internal/check-packet-render.mjs --expect-funnel", "check:series-isolation": "node internal/check-series-isolation.mjs", + "check:selector-fields": "node internal/check-selector-fields.mjs", + "check:selector-fields:self-test": "node internal/check-selector-fields.mjs --self-test", "check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs", "check:packets-all": "node internal/check-packets-all.mjs", "check:parse": "node internal/inline-parse-check.mjs", @@ -263,7 +267,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -339,6 +343,7 @@ "check:dashboard-filter-a11y": "the dashboard's cross-filter is operable and audible: every filter chip is a