UPG-100: region sections are the shared poster section, collapsible, and an 'upg100-list' poster type

UPG-100's poster wall groups its zones into regions (India, China + Mongolia,
Africa, Middle East, Central Asia, Southeast Asia + Oceania, Americas, Europe).
Those sections are now collapsible like Country Posters' and start open. They
are also available on their own as <dxm-poster-section type="upg100-list"
scope="africa">.

- data/upg100-regions.js (pure): the region table (REGION_OF, moved verbatim
  from poster-wall.vue), regionOf(id) and each region's scope slug.
- data/upg100-poster-type.js: the poster-type adapter (scopes, items, title,
  actions), built on the wall's own zones (upg100Zones), headings (regionTitle)
  and print path (the records provider with this plugin's evaluator, and
  zone-sheet-print.js).
- index.js registers the type ('upg100-list') and the element. The element
  registration is a no-op if another bundle on the page registered it first.
- poster-wall.vue groups through the same regionOf, draws the same zones, and
  passes collapsible + collapsedBy (all open). Its other props are unchanged.

check:upg100-poster-type (gates:pre) checks:
- every zone sits in a named region, with a negative control;
- the 8 sections exist, and the scopes are unique and round-trip;
- the headings exist ×11;
- the wiring holds.
Three mutants each fail it.
No library code changed.
This commit is contained in:
Doxa Maps 2026-10-02 07:58:49 +00:00
parent 8aa8568acb
commit 07b28e405e
6 changed files with 235 additions and 69 deletions

View file

@ -0,0 +1,84 @@
#!/usr/bin/env node
// check-upg100-poster-type — UPG-100's region sections are ONE section, whether the poster wall draws them or
// <dxm-poster-section type="upg100-list" scope="…"> does. Headless. Asserts:
// A. every zone the wall shows (clusters.json: lists + asia_lists + uncovered_lists) belongs to a NAMED region,
// never 'Other', and the sections the wall promises exist (India, China + Mongolia, Africa, Middle East,
// Central Asia, Southeast Asia + Oceania, Americas, Europe). NEGATIVE CONTROL: a planted unknown zone falls to
// 'Other', so this check can fail;
// B. each region's scope (its slug) is unique and round-trips from the English name, so a scope can't name two
// sections;
// C. each region's heading has its poster.region_* key in all 11 locales;
// D. the wiring: the bundle registers the 'upg100-list' type and the element; the type's adapter has the four
// parts a section reads; the wall groups through the SAME regionOf and draws the SAME zones (upg100Zones), and
// its sections are collapsible.
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const P = (...a) => join(REPO, ...a)
const PLUGIN = 'plugin-designs/my-upg-100-list'
const fails = [], notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
let R, clusters, REGION_KEY
try {
R = await import(pathToFileURL(P(PLUGIN, 'data/upg100-regions.js')).href)
clusters = JSON.parse(readFileSync(P(PLUGIN, 'data/clusters.json'), 'utf8'))
// REGION_KEY is a plain table in legend-tree.js; read it from the source rather than importing the module (it
// pulls JSON the node loader cannot take without import attributes).
const src = readFileSync(P(PLUGIN, 'data/legend-tree.js'), 'utf8')
REGION_KEY = Object.fromEntries([...src.slice(src.indexOf('export const REGION_KEY'), src.indexOf('};', src.indexOf('export const REGION_KEY'))).matchAll(/'([^']+)': '([^']+)'/g)].map((m) => [m[1], m[2]]))
} catch (e) {
console.error(`UPG100-POSTER-TYPE: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
process.exit(75)
}
// ── A ──
const ids = [...(clusters.lists || []), ...(clusters.asia_lists || []), ...(clusters.uncovered_lists || [])].map((l) => l.id).filter(Boolean)
const others = ids.filter((id) => R.regionOf(id) === 'Other')
const used = new Set(ids.map((id) => R.regionOf(id)))
const PROMISED = ['India', 'China + Mongolia', 'Africa', 'Middle East', 'Central Asia', 'Southeast Asia + Oceania', 'Americas', 'Europe']
ok(R.regionOf('a-zone-nobody-placed') === 'Other', 'NEGATIVE CONTROL: an unknown zone did not fall to Other, so this check cannot see a missing region',
'negative control: an unplaced zone falls to Other')
ok(ids.length > 20 && !others.length && PROMISED.every((r) => used.has(r)),
`zones without a region: ${others.join(', ') || '(none)'}; sections missing: ${PROMISED.filter((r) => !used.has(r)).join(', ') || '(none)'}`,
`${ids.length} zones, every one in a named region; all ${PROMISED.length} sections present`)
// ── B ──
const scopes = R.REGIONS.map(R.regionScope)
ok(new Set(scopes).size === scopes.length && R.REGIONS.every((r) => R.regionScope(R.regionScope(r)) === R.regionScope(r)) && R.regionScope('Southeast Asia + Oceania') === 'southeast-asia-oceania',
`scopes are not unique or do not round-trip: ${scopes.join(', ')}`, `scopes unique and stable: ${scopes.join(', ')}`)
// ── C ──
const missingKeys = []
for (const r of R.REGIONS) {
const key = REGION_KEY[r]
if (!key) { missingKeys.push(`${r}: no REGION_KEY`); continue }
for (const l of LANGS) { const cat = JSON.parse(readFileSync(P('library/i18n/locales', l, 'poster.json'), 'utf8')); if (typeof cat[key] !== 'string' || !cat[key].trim()) missingKeys.push(`${l} poster.${key}`) }
}
ok(!missingKeys.length, `region headings missing: ${missingKeys.slice(0, 6).join(', ')}`, `every region heading exists in all ${LANGS.length} locales`)
// ── D ──
const index = readFileSync(P(PLUGIN, 'index.js'), 'utf8')
const type = readFileSync(P(PLUGIN, 'data/upg100-poster-type.js'), 'utf8')
const wall = readFileSync(P(PLUGIN, 'poster-wall.vue'), 'utf8')
const wired = {
'the bundle registers the upg100-list type': /registerPosterType\('upg100-list', \(\) => import\('\.\/data\/upg100-poster-type\.js'\)\)/.test(index),
'the bundle registers the element': /\nregisterPosterSectionElement\(\)/.test(index),
'the adapter has scopes, items, title and actions': ['scopes:', 'items(scope, locale)', 'title(scope, t)', 'actions({'].every((s) => type.includes(s)),
'the wall groups through the same regionOf': /const regionFor = \(id\) => regionTitle\(regionOf\(id\), t\)/.test(wall) && !/const REGION_OF = /.test(wall),
'the wall draws the same zones': /return upg100Zones\(profileConfig, locale\)/.test(wall),
'the wall sections are collapsible': /:collapsible="true"/.test(wall) && /:collapsed-by="allOpen"/.test(wall),
}
const missingWires = Object.entries(wired).filter(([, v]) => !v).map(([k]) => k)
ok(!missingWires.length, `missing: ${missingWires.join('; ')}`, 'the wall and <dxm-poster-section type="upg100-list"> share one region table, one zone list and the collapsible section')
for (const n of notes) console.log(n)
if (fails.length) {
console.error('UPG100-POSTER-TYPE: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('UPG100-POSTER-TYPE: PASS — UPG-100\'s region sections are one section, on the wall and as <dxm-poster-section type="upg100-list">.')

View file

@ -242,6 +242,7 @@
"check:list-pattern-doc": "node internal/check-list-pattern-doc.mjs",
"check:pg-name-gate": "node internal/check-pg-name-gate.mjs",
"check:pin-selection": "node internal/check-pin-selection.mjs",
"check:upg100-poster-type": "node internal/check-upg100-poster-type.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.mjs",
"check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs",
"check:popup-pan-controls": "node internal/check-popup-pan-clears-controls.mjs",
@ -351,7 +352,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:script-flags 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: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:auto-engine check:engine-contract 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:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header: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 check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate",
"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 check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:upg100-poster-type",
"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",
@ -589,6 +590,7 @@
"check:popup-escaping": "the library's pin and region popups render hostile data (an <img onerror>, a <script>, a javascript: URL) as text, never markup: every field goes through escapeHtml/safeUrl (library/utils/html.js). Headless.",
"check:ported-globals-identical": "the DX Life base/_globals.scss rules that shadow-root components used to copy now come from one module (library/styles/portedGlobals.js), and the CSS each component actually INJECTS must be byte-identical to the pre-refactor baseline: it runs each component's setup with stubbed imports and hashes the real useShadowStyles argument, not the source text. Deduplication with a proof that no pixel can change. Node only, no browser.",
"check:pg-name-gate": "a people group's localized name (assets/pplr/pg-names.json) is shown only when it is a transliteration: library/utils/peopleGroupName.js falls back to English for EVERY locale of a row whose Latin-script names are far from the English one in two or more locales (bigram Dice < 0.5), because about a third were machine-translated as words (Balantak → 'Balance', Balo → 'Sac à dos'). A row with fewer than two Latin names cannot be checked and shows English too. Proved on the real table: the 4 known translated rows show English in 7 locales, Balangao Bontoc keeps its names, planted rows AT the threshold (exactly two far → English, one → kept, one Latin name → English), the whole-table fallback stays 30-45% (38.2%), and no file outside the helper reads or indexes pg-names rows (pplr-data-maps carded; the scanner is negative-controlled both ways). Headless.",
"check:upg100-poster-type": "UPG-100's region sections are ONE section on the poster wall and as <dxm-poster-section type=\"upg100-list\">: every zone in clusters.json belongs to a named region (a planted unknown zone falls to Other: the negative control), the 8 promised sections exist, each scope slug is unique and round-trips, each heading has its poster.region_* key in all 11 locales, and the wiring holds (the bundle registers the type and the element; the wall groups through the same regionOf, draws the same zones and its sections are collapsible). Headless.",
"check:pin-selection": "the shared source reader understands BOTH engines: mapbox-gl stores a GeoJSON source _data as the FeatureCollection, maplibre-gl wraps it as { geojson } (or { url } when remote). Reading only the Mapbox shape returned an EMPTY list on MapLibre — the keyless engine public builds use — which silently killed zoomToLegendRow camera fits on three maps and the research packet row selection, with no error because every caller no-ops on no features. Asserts both shapes, that a remote source returns [] AND warns, the serialize fallback, and that selectedPinIds agrees with the reader. Headless, no map. Exit 0 pass, 1 fail.",
"check:popup-href-escaped": "a URL interpolated into an HTML string is escaped before it lands in href= or src=. Written for RegionCountryMap's popup, which escaped every value except its two link URLs -- both built from joinBase(hostAttribute, apiSlug), so a quote in either escaped the attribute. Data URLs, blob URLs, encodeURIComponent and safeUrl() are recognised as safe; anything else needs an href-escaped-ok: reason. 6 self-tests. Source-only, headless.",
"check:poster-action-print": "a poster button opens the print dialog (the rule since 2026-09-28: never a direct download). Two arms: PosterWall's posterAction defaults to 'print' and any consumer opting into 'download' carries a posterAction-download: comment saying why; and no shipped file hand-rolls an <a download> for a poster IMAGE (svg/png/webp/pdf) without first attempting to print, which the prop arm cannot see. Data exports (csv/txt/json) are out of scope. 6 self-tests. Source-only, headless.",

View file

@ -0,0 +1,60 @@
/**
* The 'upg100-list' poster type: the UPG-100 zone posters, one section per region. Registered from this plugin
* (index.js, registerPosterType), so <dxm-poster-section type="upg100-list" scope="africa"> draws exactly the section
* the UPG-100 poster wall draws. The same zones (zonesForList, list-aware), the same region of each zone
* (upg100-regions.js), the same headings (poster.region_*), and the same print path (the library's records provider
* with this plugin's selector evaluator, and zone-sheet-print.js for the sheet).
* scope = a region's key in either spelling: the slug ("southeast-asia-oceania") or the English name.
*/
import { zonesForList } from './zones-for-list.js'
import { regionOf, regionScope, REGIONS } from './upg100-regions.js'
import { REGION_KEY } from './legend-tree.js'
import { zoneTitle } from './zone-title.js'
import { evaluate as evaluateSelector } from './selector-evaluator.js'
import { makeZoneSheet } from './zone-sheet-print.js'
import { resolvePeopleGroupList } from '@map/lists/peopleGroupList.js'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { countryName } from '@map/utils/countryName.js'
import { keyedError } from '@map/i18n/keyedError.js'
/**
* Every zone the wall shows for the list in force, its card named in the reader's language. zoneTitle declines
* (null) for a zone it cannot name, and that zone keeps its hand-written label, exactly as on the legend and the
* packet.
*/
export function upg100Zones(profileConfig, locale) {
const z = zonesForList(resolvePeopleGroupList(profileConfig))
return [...(z.lists || []), ...(z.asia_lists || []), ...(z.uncovered_lists || [])]
.filter((l) => l && l.id)
.map((l) => { const title = zoneTitle(l, null, { countryName, locale }); return title ? { ...l, label: title } : l })
}
/** A region's heading in the reader's language (poster.region_*, in all 11 locales); the English key if none. */
export function regionTitle(english, t) {
const key = REGION_KEY[english]
if (!key) return english
const v = t(`poster.${key}`)
return v && v !== `poster.${key}` ? v : english
}
const englishOf = (scope) => REGIONS.find((r) => regionScope(r) === regionScope(scope)) || null
export default {
scopes: () => REGIONS.map(regionScope),
items(scope, locale) {
return upg100Zones(null, locale).filter((l) => regionScope(regionOf(l.id)) === regionScope(scope))
},
title(scope, t) {
const english = englishOf(scope)
return english ? regionTitle(english, t) : String(scope || '')
},
actions({ assetsBase, apiBase, lang, profileConfig }) {
const recordsFor = createPeopleGroupRecords({ apiBase, lang, profileConfig, evaluate: (selector, records) => evaluateSelector(selector, records) })
return {
records: (list) => (apiBase() ? recordsFor(list.selector) : Promise.reject(keyedError('wall.hud.noApi', 'no people-groups service is configured'))),
sheet: makeZoneSheet({ allRecords: () => recordsFor.all(), assetsBase, profileConfig }),
unsupported: () => null,
unit: 'UPG', itemCountKey: 'wall.listCount', eventName: 'doxa-upg-100-list:show-on-map', downloadPrefix: 'doxa-upg-100', cardStocks: true,
}
},
}

View file

@ -0,0 +1,61 @@
// upg100-regions.js — which REGION SECTION each UPG-100 zone belongs to, and that section's scope key. PURE (no
// imports), so the poster wall, the 'upg100-list' poster type (upg100-poster-type.js) and the gate read ONE table.
// Moved here verbatim from poster-wall.vue, which groups by it as before.
// ─── REGIONAL SECTIONS ───────────────────────────────────────────────────────
// clusters.json carries no region field, so the mapping lives here. It is keyed by
// list id rather than derived from the selector: a selector can be a wagf_block, a
// country_codes array or an explicit pg_slugs list, and none of those reliably
// implies the continent a reader expects to browse by.
//
// SE Asia + Oceania are ONE section deliberately (the island chains read as a
// single sweep), and the Americas gathers Brazil, the Andean/Caribbean list and the
// US-bearing scatter list.
export const REGION_OF = {
// Africa
'west-africa': 'Africa', 'sudan': 'Africa', 'east-africa': 'Africa',
'central-africa': 'Africa', 'southern-africa-islands': 'Africa',
// Middle East. Maghreb+Egypt is geographically North AFRICA but belongs here:
// the region is read as a cultural/linguistic sweep.
'maghreb-egypt': 'Middle East',
'middle-east-core': 'Middle East',
// CENTRAL ASIA is its own section: the 55-UPG Pakistan map belongs to Central
// Asia, not the Middle East. It had been folded into the Middle
// East as "the same sweep continuing east", but Pakistan, Afghanistan,
// Uzbekistan, Tajikistan, Kyrgyzstan and Kazakhstan are Central/South Asia —
// filing them under the Middle East misnames six countries to save a heading.
'uncovered-nas-a': 'Central Asia',
// Southeast Asia + Oceania
'laos': 'Southeast Asia + Oceania', 'oceania-indonesia': 'Southeast Asia + Oceania',
'mainland-se-asia-philippines': 'Southeast Asia + Oceania',
// Americas
'brazil': 'Americas', 'andean-caribbean': 'Americas', 'uncovered-scatter': 'Americas',
// Europe
'europe': 'Europe',
// India and China are their OWN sections, not one lump called "Asia". Between
// them they are 1,122 people groups across 12 lists — more than every other
// section combined — so collapsing them hides the two largest prayer fields in
// the dataset behind a single continental label. Matched by id prefix below.
}
/** The section a zone belongs to, by its ENGLISH grouping key (REGION_OF, then the India / China id prefixes). */
export function regionOf(id) {
if (REGION_OF[id]) return REGION_OF[id]
if (String(id).startsWith('asia-india-')) return 'India'
// "China + Mongolia": the northern zone declares Mongolia as a member country (its land is drawn and highlighted),
// so the section heading names both rather than folding a sovereign country under a China label.
if (String(id).startsWith('asia-china-')) return 'China + Mongolia'
return 'Other'
}
/**
* A section's SCOPE for <dxm-poster-section type="upg100-list" scope="…">: its English key slugified the way DX
* Life's /regions does (no diacritics, lowercase, "&"/"+" → a dash, non-alphanumerics → "-"), so "Southeast Asia +
* Oceania" is "southeast-asia-oceania" and the English name itself is accepted too.
*/
export const regionScope = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
.replace(/['’.]/g, '').replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'other'
/** Every section's English key, in a stable order (the wall itself sorts its sections by size). */
export const REGIONS = ['India', 'China + Mongolia', 'Africa', 'Middle East', 'Central Asia', 'Southeast Asia + Oceania', 'Americas', 'Europe', 'Other']
export default { REGION_OF, regionOf, regionScope, REGIONS }

View file

@ -1,4 +1,6 @@
import { defineMapElement } from '@map/elements/defineMapElement.js'
import { registerPosterType } from '@map/components/poster/posterTypes.js'
import { registerPosterSectionElement } from '@map/elements/posterSectionElement.js'
/**
* plugin-designs/my-upg-100-list/index.js
*
@ -64,4 +66,11 @@ if (typeof customElements !== 'undefined') {
}
}
// ★ THE 'upg100-list' POSTER TYPE, registered from this plugin (no plugin code in the library): one UPG-100 region
// section as an element, <dxm-poster-section type="upg100-list" scope="africa">, drawing the same section the poster
// wall draws (data/upg100-poster-type.js). The element is registered here too (a no-op when another bundle on the
// page did it first), so a page that loads only this bundle can show it.
registerPosterType('upg100-list', () => import('./data/upg100-poster-type.js'))
registerPosterSectionElement()
export default Upg100ListMapElement

View file

@ -12,7 +12,7 @@
* its records provider.
*
* WHAT COULD NOT BE SHARED, and why each is here rather than in the component:
* REGION_OF / regionFor — the 100-list's own continental taxonomy, keyed by list id because a
* regionFor (REGION_OF in data/upg100-regions.js) — the 100-list's own continental taxonomy, keyed by list id because a
* selector can be a wagf_block, a country_codes array or an explicit
* pg_slugs list and none of those implies a continent.
* fetchListRecords — knows the people-groups API, the selector evaluator AND the pg_slugs
@ -41,7 +41,6 @@
import { computed, inject, ref } from 'vue'
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { resolvePeopleGroupList } from '@map/lists/peopleGroupList.js'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
// The reader's language, for the DATA fetch below. Not for interface text — that goes through t().
@ -49,18 +48,15 @@ import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
// The list's total for the intro, from the funnel (never a number written into a string; see data/upg-total.js).
import visionFunnel from '../../data/people-groups/vision-funnel.json'
import { upgTotal, countInSentence } from './data/upg-total.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
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 { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { makeZoneSheet } from './data/zone-sheet-print.js'
import { REGION_KEY } from './data/legend-tree.js'
import { regionOf } from './data/upg100-regions.js'
// The zones (list-aware, each named in the reader's language) and each region's heading: the 'upg100-list' poster
// type's own functions, so this wall and <dxm-poster-section type="upg100-list"> draw the same cards.
import { upg100Zones, regionTitle } from './data/upg100-poster-type.js'
import { keyedError } from '@map/i18n/keyedError.js'
// A zone's title in the reader's language (tier A: its country; others keep their label) — the same
// generator the map's legend and the packet use, so a card, its alt text and its print say one name.
import { zoneTitle } from './data/zone-title.js'
import { countryName } from '@map/utils/countryName.js'
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
// The table the zone map sheets were generated from (generate.mjs labels each country with it), so a
@ -82,41 +78,7 @@ const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|| import.meta.env.VITE_API_BASE_URL
|| ''
// ─── REGIONAL SECTIONS ───────────────────────────────────────────────────────
// clusters.json carries no region field, so the mapping lives here. It is keyed by
// list id rather than derived from the selector: a selector can be a wagf_block, a
// country_codes array or an explicit pg_slugs list, and none of those reliably
// implies the continent a reader expects to browse by.
//
// SE Asia + Oceania are ONE section deliberately (the island chains read as a
// single sweep), and the Americas gathers Brazil, the Andean/Caribbean list and the
// US-bearing scatter list.
const REGION_OF = {
// Africa
'west-africa': 'Africa', 'sudan': 'Africa', 'east-africa': 'Africa',
'central-africa': 'Africa', 'southern-africa-islands': 'Africa',
// Middle East. Maghreb+Egypt is geographically North AFRICA but belongs here:
// the region is read as a cultural/linguistic sweep.
'maghreb-egypt': 'Middle East',
'middle-east-core': 'Middle East',
// CENTRAL ASIA is its own section: the 55-UPG Pakistan map belongs to Central
// Asia, not the Middle East. It had been folded into the Middle
// East as "the same sweep continuing east", but Pakistan, Afghanistan,
// Uzbekistan, Tajikistan, Kyrgyzstan and Kazakhstan are Central/South Asia —
// filing them under the Middle East misnames six countries to save a heading.
'uncovered-nas-a': 'Central Asia',
// Southeast Asia + Oceania
'laos': 'Southeast Asia + Oceania', 'oceania-indonesia': 'Southeast Asia + Oceania',
'mainland-se-asia-philippines': 'Southeast Asia + Oceania',
// Americas
'brazil': 'Americas', 'andean-caribbean': 'Americas', 'uncovered-scatter': 'Americas',
// Europe
'europe': 'Europe',
// India and China are their OWN sections, not one lump called "Asia". Between
// them they are 1,122 people groups across 12 lists — more than every other
// section combined — so collapsing them hides the two largest prayer fields in
// the dataset behind a single continental label. Matched by id prefix below.
}
// ─── REGIONAL SECTIONS: data/upg100-regions.js (moved there so the 'upg100-list' poster type reads the same table) ─
// ⚠ TAKES AN ID, while the shared wall's groupBy takes the whole ITEM. The template
// adapts it — `:group-by="(item) => regionFor(item.id)"` — rather than changing this
// signature, because the id is what the taxonomy is keyed by and every prefix rule
@ -128,19 +90,11 @@ const REGION_OF = {
// grouping — and the heading a reader sees is resolved here, per locale, through REGION_KEY
// (data/legend-tree.js), the one table the legend's region rows translate through as well. Every
// heading has a poster.region_* key in all 11 locales, India and Other included.
const regionLabel = (english) => {
const key = REGION_KEY[english]
return key ? t(`poster.${key}`) : english // no key yet -> the English heading, not a raw key
}
const regionFor = (id) => {
if (REGION_OF[id]) return regionLabel(REGION_OF[id])
if (id.startsWith('asia-india-')) return regionLabel('India')
// "China + Mongolia": the northern zone declares Mongolia as a member country
// (its land is drawn and highlighted), so the section heading names both rather
// than folding a sovereign country under a China label.
if (id.startsWith('asia-china-')) return regionLabel('China + Mongolia')
return regionLabel('Other')
}
const regionFor = (id) => regionTitle(regionOf(id), t)
// ★ COLLAPSIBLE, LIKE COUNTRY POSTERS (each region section is the shared PosterSection, through the wall). Every
// section starts OPEN: a reader who knew the wall finds it as before, and can now fold away the regions they are
// not praying for. (Country Posters starts its small regions shut; UPG-100's regions are all large.)
const allOpen = () => false // no key yet -> the English heading, not a raw key
// 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
@ -156,21 +110,15 @@ const regionFor = (id) => {
// this list IS, and the old comment was describing the wrong thing.
const { locale: wallLocale } = useOptionalI18n('')
const items = computed(() => {
const z = zonesForList(resolvePeopleGroupList(profileConfig))
// The i18n locale REF, read here so this computed re-runs on a runtime language switch: getRuntimeLocale()
// is a plain getter, and a computed that reads only it is resolved once and never again (a frozen label).
const l = wallLocale && (wallLocale.value ?? wallLocale)
const locale = (typeof l === 'string' && l) || getRuntimeLocale() || detectLocale()
return [
...(z.lists || []),
...(z.asia_lists || []),
...(z.uncovered_lists || []),
].filter(l => l && l.id)
// ★ THE CARD'S NAME IN THE READER'S LANGUAGE. item.label is the Show-on-map button's accessible
// name, every poster's alt text and tooltip, the print HUD's title and the printed documents' title
// — all five were English in every locale. zoneTitle declines (null) for a zone it cannot name, and
// that zone keeps its hand-written label, exactly as on the legend and the packet.
.map(l => { const title = zoneTitle(l, null, { countryName, locale }); return title ? { ...l, label: title } : l })
// ★ THE CARD'S NAME IN THE READER'S LANGUAGE (item.label is the Show-on-map button's accessible name, every
// poster's alt text and tooltip, the print HUD's title and the printed documents' title). The zones and their
// names come from the 'upg100-list' poster type's own function, so this wall and <dxm-poster-section
// type="upg100-list"> can never show different cards.
return upg100Zones(profileConfig, locale)
})
// ─── THE RECORDS PROVIDER — now the library's (lists/peopleGroupRecords.js) ─────────
@ -292,6 +240,8 @@ const wallTag = computed(() => t('messages.upg100WallTag'))
download-prefix="doxa-upg-100"
:columns-control="true"
:min-card-px="150"
:collapsible="true"
:collapsed-by="allOpen"
/>
<!-- min-card-px 150: "Posters per row: 2" now SHOWS two on a phone. The shared clamp (210px) forced one at
390 and silently overrode the select, which read 2 (2026-09-30: work like the country posters,