diff --git a/plugin-designs/my-upg-100-list/poster-wall.vue b/plugin-designs/my-upg-100-list/poster-wall.vue index 620849c56..2879c81e7 100644 --- a/plugin-designs/my-upg-100-list/poster-wall.vue +++ b/plugin-designs/my-upg-100-list/poster-wall.vue @@ -41,11 +41,11 @@ import { computed, inject, ref } from 'vue' import { useOptionalT } from '@map/composables/useOptionalT.js' // The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups. -import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js' +import { applyPeopleGroupList, resolvePeopleGroupList } from '@map/lists/peopleGroupList.js' import PosterWall from '@map/components/poster/PosterWall.vue' import { resolveAssetsBase } from '@map/utils/embedBase.js' // Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out. -import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json' +import { zonesForList } from './data/zones-for-list.js' // The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live // /people-groups/list payload + a list's `selector` into the exact rows that belong to it. import { normalizeRecords, extractRecords } from './data/normalize.js' @@ -115,12 +115,27 @@ const regionFor = (id) => { if (id.startsWith('asia-china-')) return 'China + Mongolia' return 'Other' } -// One flat list from the three cluster groups — the same 27 the map paints. -const items = computed(() => [ - ...(clusterLists || []), - ...(clusterAsiaLists || []), - ...(clusterUncoveredLists || []), -].filter(l => l && l.id)) +// One flat list of the zones the map paints — for WHICHEVER list is showing. +// +// ★ THROUGH zonesForList, not a static clusters.json import (pattern work, 2026-09-27). This wall already +// filtered its RECORDS by list (applyPeopleGroupList, below), so before this it showed another list's +// people groups arranged under UPG-100's zone names — half list-aware, which is the worst of the two +// states because it looks right. Now the zones follow the list too. +// Safe inside a computed: profileConfig is injected at :54 and this evaluates lazily, after setup — +// a module-level call would hit the temporal dead zone (it does in upg-100-list.vue, which is why that +// file resolves per call as well). +// ⚠ AND THE OLD COMMENT SAID "the same 27", which was already stale: the legend has 28 leaves, because +// buildLegendNodes collapses the two deaf_lists into one unified row. This spread still yields 27 because +// it reads the three geographic buckets and the deaf row is synthesised — so the number is right for what +// this list IS, and the old comment was describing the wrong thing. +const items = computed(() => { + const z = zonesForList(resolvePeopleGroupList(profileConfig)) + return [ + ...(z.lists || []), + ...(z.asia_lists || []), + ...(z.uncovered_lists || []), + ].filter(l => l && l.id) +}) // ─── THE RECORDS PROVIDER — the one thing that cannot be shared ────────────── async function fetchListRecords(list) { diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index 21472cddf..d0fec33ab 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -83,9 +83,15 @@ import { ensureEngineScript, engineGlobal, engineStylesheetUrl } from '@map/comp // registration only ever worked by accident. import { installRtlTextPlugin } from '@map/maps/rtl-text.js' import { resolveNamedStyle } from '@map/styles/registry.js' -// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out. -import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json' -const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists } +// ★ NO DIRECT clusters.json IMPORT ANY MORE — every zone read goes through listZones() so the legend, the +// list, the selectors and the packet all see the SAME zones for whatever list is showing. The named-import +// tree-shaking this line used to protect now lives in zones-for-list.js, which keeps a static named import +// for exactly that reason. +// ⚠ FOUR SITES READ THE ZONES, not one. allLists()'s own note promised the `list` swap would be "a one-line +// change instead of an audit"; it was four, found by grepping the IDENTIFIER rather than trusting the note: +// allLists, bootstrapLegend (which also attaches selectors), and two legend rebuilds that pass the whole +// object so a `clusters.` grep misses them. Recorded so the next reader greps too. +import { zonesForList } from './data/zones-for-list.js' import { normalizeRecords, extractRecords } from './data/normalize.js' import { useI18n } from 'vue-i18n' // The in-place print helper: a hidden same-page iframe, NOT window.open(). A popup @@ -1424,9 +1430,22 @@ async function printPrayerPacket(zoneId) { // universal `list` prop lands (exp-shepherd Tier A: profile-config.list / ?list= / window.MAP_LIST, // default 'doxa') it replaces the body here and nothing else moves. Do not read `clusters` directly // anywhere else; that is what makes the swap a one-line change instead of an audit. +// ★ AND THIS IS THAT SWAP, taken 2026-09-27 — the body, exactly as the note above predicted, with the +// static `clusters` import left untouched so its tree-shaking survives (zones-for-list.js keeps the named +// import for that reason). resolvePeopleGroupList reads config.list / ?list= / window.MAP_LIST, so a +// partner already choosing a LIST now also gets that list's ZONES. +// ⚠ RESOLVED PER CALL, NOT ONCE AT SETUP. `profileConfig` is injected at :126 — after the import block — +// so a module-level `const clusters = zonesForList(...)` throws on the temporal dead zone. It is also more +// correct per call: profileConfig is a ref a host can change, and the list can arrive later than setup. +// Cheap, because zonesForList is a registry lookup returning a shared object, not a build. +function listZones() { + return zonesForList(resolvePeopleGroupList(profileConfig)); +} + function allLists() { - return [...(clusters.lists || []), ...(clusters.asia_lists || []), - ...(clusters.uncovered_lists || [])]; + const z = listZones(); + return [...(z.lists || []), ...(z.asia_lists || []), + ...(z.uncovered_lists || [])]; } // ── THE PAGE-2 FUNNEL CONTEXT ─────────────────────────────────────────────── @@ -1893,14 +1912,21 @@ function findLegendNode(nodes, key) { // (both are plain module-scope `let`s, so building them early is free). // Idempotent: re-running it just rebuilds the same tree from the same static input. function bootstrapLegend() { - const nodes = buildLegendNodes(clusters, { deafCount: DEAF_COUNT }) + // ⚠ THE SAME ZONES allLists() USES, via listZones(), and this is the SECOND site — the note on + // allLists promised the `list` swap would be "a one-line change instead of an audit" and that was not + // quite true: this function reads the cluster buckets DIRECTLY to attach selectors. Left on the static + // import it would have built the legend from UPG-100's zones while the list showed another list's, and + // the two would disagree with nothing reporting it. Found by grepping for `clusters.` rather than by + // trusting the convention comment. + const z = listZones() + const nodes = buildLegendNodes(z, { deafCount: DEAF_COUNT }) legendNodes.value = nodes leafZoneList = leafZones(nodes) const byId = {} - for (const l of clusters.lists || []) byId[l.id] = l.selector - for (const a of clusters.asia_lists || []) byId[a.id] = a.selector + for (const l of z.lists || []) byId[l.id] = l.selector + for (const a of z.asia_lists || []) byId[a.id] = a.selector // slice 09b-6 §1a: the 7 Frontier-Asia uncovered zones (country_codes → ADM0). - for (const u of clusters.uncovered_lists || []) byId[u.id] = u.selector + for (const u of z.uncovered_lists || []) byId[u.id] = u.selector byId['deaf'] = { type: 'affinity_bloc', rop1: 'A017' } for (const z of leafZoneList) z.selector = byId[z.id] matchColorExpr = buildMatchColorExpr(leafZoneList) @@ -1920,7 +1946,7 @@ async function enrichLegendFromRecords() { if (!resp.ok) throw new Error(`list ${resp.status}`) const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig)) countryNameMap = buildCountryNameMap(records) - legendNodes.value = buildLegendNodes(clusters, { deafCount: DEAF_COUNT, records }) + legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records }) } catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ } } @@ -2240,7 +2266,7 @@ onMounted(async () => { // Re-shape the legend tree WITH records so every node carries `pop` (slice // 04u §1b). Selectors/colors/counts are unchanged; only `pop` is added. - legendNodes.value = buildLegendNodes(clusters, { deafCount: DEAF_COUNT, records }) + legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records }) // ── pins layer (kept from 04b, now stamped with zoneKey for isolate dim) ──