doxa-maps/plugin-designs/my-upg-100-list/upg-100-list.vue
Doxa Maps 92abbb468b
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source ccc2b1bb
2026-09-26 08:35:22 +00:00

2538 lines
150 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* upg-100-list.vue — my-upg-100-list · slice 04t THE 26-ZONE POLYGON TAPESTRY
*
* @description A personal unreached-people-groups list painted as true boundary polygons —
* all 26 affinity zones drawn at once from a single Mapbox fill source, with the shared
* nested semantic tree legend driving the colours. The map half of the my-upg-100-list
* bundle, whose companion profile is the printable poster wall.
*
* Upgrade of slice 04b (which colored PINS) → true BOUNDARY polygons:
* - consumes the forge-shared <SemanticTreeLegend> (v2, nested 26-row tree) —
* does NOT rebuild a legend (§1a / S04t-D4)
* - paints ALL 26 zones at once via ONE Mapbox fill source + a single
* ['match', ['get','zoneKey'], …] expression (§1d, heap-safe)
* · regional (13) + deaf (1) → ADM0 country polygons (data/adm0/world-adm0.geojson)
* · asia (12) → ADM1 state-cluster polygons (gb-IND-ADM1 / gb-CHN-ADM1)
* - row-click ISOLATES one zone (hide other 25, fly to bounds), re-click restores
* the full tapestry (§1e / S04t-D5) — polygon form of slice 05.
*
* Heap safety (Vue+Mapbox budget): geojson held markRaw/shallowRef, ADM0/ADM1
* data lazy-import()-ed, ONE source + match paint (not 26 layers), theme toggle
* via a single setStyle(style,{diff:false}).
*/
import { inject, provide, ref, shallowRef, markRaw, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
// resolvePeopleGroupList/listInfo/LIST_DEFAULT are for the PACKET'S FUNNEL page, which must state the
// total of the list the packet was actually drawn from — see packetFunnel().
import {
applyPeopleGroupList, resolvePeopleGroupList, listInfo, LIST_DEFAULT,
} from '@map/lists/peopleGroupList.js'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
// The shared mobile bottom-sheet. Fed this profile's own tree through the `nodes`
// override so the mobile legend is the SAME component every other map uses.
import LegendMobile from '@map/components/legends/LegendMobile.vue'
// 09b-9 legend-light-mode: the shared SemanticTreeLegend is ALREADY theme-aware —
// it renders :data-theme="instance?.theme.value || 'dark'" and ships a complete
// [data-theme="light"] stylesheet (SemanticTreeLegend.vue:488-692). It stayed dark
// here only because THIS profile never created/provided a pplrInstance, so
// useInstance() returned null and the legend fell back to 'dark'. We create + provide
// an instance and drive instance.theme from isDark — the SAME mechanism research-map
// uses (the QA cited research-map.vue:159,570). Zero blast radius: the shared
// component is untouched; the other 2 apps that don't provide an instance still get
// the 'dark' fallback unchanged. (Chosen over the QA's app-scoped useShadowStyles
// override per QA §1c "try scoped-CSS first; report which" — using the component's
// own built-in light theme avoids duplicating ~60 CSS rules that would drift.)
import { createPplrInstance, provideInstance } from '@map/composables/usePplrInstance.js'
// slice 13 §1a: pin-click → detail side panel. CONSUME the shared
// PeopleGroupDetail (never edit src/). defineAsyncComponent so its ~115 KB only
// loads on the first pin click — exactly the research-map pattern
// (research-map.vue:152). The component reads inject('uiStore') (provided by
// ProfileLoader) + useI18n() (installed by this bundle's index.js), and drives
// its OWN visibility/close via uiStore.selectPeopleGroup(feature|null) — it has
// NO @close emit, so we gate the panel on uiStore.selectedPeopleGroup and let
// its built-in close button clear the store.
const PeopleGroupDetail = retryableAsyncComponent(() => import('@map/components/PeopleGroupDetail.vue'))
// Shared map-control components (CONSUME — never edit src/). Same set + ordering
// the research-map mounts (research-map.vue:1838-1845). MapToolbar is the slot
// wrapper that self-positions top-right; the buttons are its children.
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
// The shared base button — the batch/select control uses this instead of a
// hand-rolled <button>, so it is the same size and shape as every other control.
import MapControlButton from '@map/components/map-controls/MapControlButton.vue'
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import LocationButton from '@map/components/map-controls/LocationButton.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vue'
// 09b-search-reuse-geocoder §1(b): CONSUME the shared geocoder search UI the
// research-map already mounts (top-left, ABOVE the legend, mobile-friendly,
// wraps mapbox-gl-geocoder). It builds its OWN localGeocoder from
// inject('dataStore') via useGeoSearch — it has NO localGeocoder prop — so we
// feed it by populating dataStore.sources (cacheSourceData) with a unified PG +
// zone + territory record list (data/search-index.js → buildGeocoderFeed). The
// component emits `people-group-result`; the profile branches on the synthetic
// `__upgKind` to route PG→detail / zone→fitBounds / territory→fitBounds. Never
// edits src/ (zero blast radius — md5 unchanged). (research-map.vue:141,1815.)
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
// Shapes Arabic / Persian / Hebrew BASEMAP labels. A raw map inherits nothing from the bundle
// entry, so this profile has to install it itself — see the call site below for why the entry's
// 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 }
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
// is blockable and drops the user out of the map; the iframe opens the print dialog
// right here. Shared so the packet, the poster and the card deck all print the same way.
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { buildLegendNodes, leafZones } from './data/legend-tree.js'
import { resolveAllZones } from './data/zone-geometry.js'
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
// holds the region-inherited tapestry (purple ME, red Africa, green
// Europe, orange Oceania, yellow LatAm, deaf #18ffff). colorForZone(zoneKey,
// leafColorFallback) lets the IDLE tapestry-fill paint that strategy WITHOUT a
// click — override wins, else the deterministic leafColor() rainbow fallback.
import { colorForZone } from './data/zone-colors.js'
// 09b shift-select tool §1(b): name→count resolver. Pure data + pure fns; the
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
// from the live records (PG), the legend leafZones (zone), and admin-counts +
// ISO3 names (territory), then matches per keystroke. No shared-src edit — the PG
// search consumes the SAME live records useGeoSearch would; we extend the result
// set to zones + territories per the QA (a unified {label,kind,target} list).
// 09b-search-reuse-geocoder §1(b): buildGeocoderFeed unifies PG + zone +
// territory into the record list the SHARED GeocoderComponent's useGeoSearch
// indexes. (matchSearch/buildSearchIndex retained for the headless gate handle.)
import { buildSearchIndex, buildGeocoderFeed, matchSearch } from './data/search-index.js'
const mapboxToken = inject('mapboxToken', ref(''))
const profileConfig = inject('profileConfig', ref(null))
// ── THIS PROFILE BUILDS ITS MAP RAW, SO IT HAS TO HONOUR THE SWITCH ITSELF ───
// Every other map bundle reaches the provider switch for free because useMapInstance resolves it.
// This one calls `new gl.Map` directly (see onMounted), so it was the ONE map the switch could not
// move: MEASURED at ?provider=maplibre it still loaded Mapbox and made 19 requests to
// api.mapbox.com while five sibling leaves swapped cleanly.
// The engine loader is IMPORTED from useMapInstance rather than re-declared here — a second copy of
// the ENGINES table in a profile is how two copies of one fact drift apart.
const upgProvider = resolveMapProvider(profileConfig)
const isMaplibre = upgProvider === 'maplibre'
// The geocoder is deferred to Mapbox by decision (the agent 2026-09-24): GeocoderComponent's maplibre
// path still calls makeMapboxForwardGeocode(accessToken), so the SEARCH REQUEST goes to Mapbox
// whichever engine draws the tiles — a keyless map with a search bar is not keyless.
const hasGeocoder = computed(() => {
if (profileConfig?.value?.search === false) return false
return upgProvider === 'mapbox'
})
// EDGE SCROLL AFFORDANCE — the gray accessibility side bars that tell you the page
// scrolls PAST the map. Feature-flagged per embed, because it is a promise: a map
// with nothing beneath it must not advertise a scroll escape that goes nowhere.
// Host override: <my-upg-100-list-map edge-scroll-affordance="false">, or
// config.edgeScrollAffordance. Defaults TRUE here because this profile is the first
// to get a pane column (map on top, poster wall below).
const EDGE_SCROLL_AFFORDANCE = computed(() => {
const v = profileConfig.value?.edgeScrollAffordance
return v === undefined || v === null ? true : !(v === false || v === 'false')
})
// 09b-search-reuse-geocoder §1(b): the Pinia dataStore ProfileLoader provides
// (ProfileLoader.vue:103). The SHARED GeocoderComponent injects this same store
// and reads dataStore.sources[*].features through useGeoSearch. We push our
// unified feed into it via cacheSourceData so the shared search lights up.
const dataStore = inject('dataStore', null)
const GEOCODER_SOURCE = 'upg-search'
// slice 13 §1c: the canonical Pinia uiStore, provided by ProfileLoader
// (ProfileLoader.vue:98-101). PeopleGroupDetail injects the SAME store, so the
// panel opens when we call uiStore.selectPeopleGroup(feature) and hides itself
// when its close button calls uiStore.selectPeopleGroup(null). Template gates on
// uiStore.selectedPeopleGroup. Default {} so a stray standalone mount can't crash.
const uiStore = inject('uiStore', null)
const mapEl = ref(null)
const status = ref('loading…')
// P3-1: the MAP CELL's own error. Distinct from `status` (the small bottom-right
// telemetry badge): this is the user-facing "the map is unavailable" notice drawn
// INSIDE the map cell, so a tile/token/WebGL failure degrades to a missing map
// rather than a blank app. Mirrors gospel-resources' `err` + .gm-err.
const mapError = ref('')
const legendNodes = shallowRef([]) // nested 26-row tree for SemanticTreeLegend
const selectedKey = ref(null) // currently isolated zoneKey (or null)
// Reactive mirror of `map` for the control components (they take :map as a prop and
// need reactivity to mount once the instance exists). `map` itself stays a plain
// `let` for the imperative draw/layer code below.
const mapRef = shallowRef(null)
const isDark = ref(false) // 09b light-default: boot style is light-v11 (see onMounted)
// 09b-9 legend-light-mode: per-map instance store. SemanticTreeLegend injects this
// (useInstance) and reads instance.theme for its :data-theme binding. We provide it
// at mount and keep instance.theme in lock-step with isDark so the legend follows the
// basemap: boot isDark=false → theme 'light' → legend light; toggle dark → 'dark' →
// the component's own (default) dark chrome. Seed 'light' to match the light boot.
const pplrInstance = createPplrInstance('my-upg-100-list')
pplrInstance.theme.value = 'light' // boot is light-v11 (isDark=false)
provideInstance(pplrInstance)
// ── 09b shift-select tool: reactive batch-selection state ────────────────────
// selectedTerritories is an ORDERED list of { key, name, count, kind } (insertion
// order = the intended click order → the clipboard reads in that order). A plain
// Set would lose order; an array gives ordered names + cheap toggle by `key`.
const selectedTerritories = ref([]) // [{ key, name, count, kind }]
const selTotal = computed(() => // live running UUPG total (null counts = 0)
selectedTerritories.value.reduce((a, t) => a + (Number(t.count) || 0), 0))
const selCount = computed(() => selectedTerritories.value.length)
const copyFlash = ref('') // transient "Copied!" / "Copy failed" label
let countryNameMap = null // iso3→label, built from live records
// ── R1.2 PIVOT (design note 2026-06-11): MOBILE-FRIENDLY "Batch Select" mode ──────
// The selection INPUT GESTURE is a visible toggle button + plain taps — NO
// shift/ctrl/meta (touch devices have no modifier keys; D-8 removes them). When
// batchMode is ON a plain tap on a map `tapestry-fill` feature OR a legend row
// toggles that territory/zone in/out of selectedTerritories (a″/a‴). When OFF a
// plain tap is the existing single-zone isolate (a‴, unchanged). Leaving batch
// mode KEEPS the selection STATE so Copy/color still work (D-7); Clear empties it.
//
// R1.4 (L4 fix — Brief: "should just be a single button and only should show ui
// to batch select if you select the button"): the map-rail .upg-rail-batch is the
// ONLY entry point (the always-visible HUD .upg-batch-btn is GONE), and ALL batch
// chrome — HUD, legend row markers, the amber map highlight — renders ONLY while
// batchMode is ON (progressive disclosure). The kept selection is invisible out
// of batch mode and re-reveals on re-entry.
const batchMode = ref(false)
function setBatchMode(on) {
const next = on === undefined ? !batchMode.value : !!on
if (next === batchMode.value) return next // idempotent: repeat sets don't churn
batchMode.value = next
if (map) map.getCanvas().style.cursor = '' // reset hover cursor on mode flip
// R1.4: show/hide ALL batch chrome (map highlight + legend markers) in the SAME
// pass as the mode flip — no frame where the mode and its chrome disagree.
refreshSelectionPaint()
return next
}
function toggleBatchMode() { setBatchMode() }
// R1.3 (j) + R1.4: DOM-settle pass for the legend's multi-row markers across
// batch-mode flips and theme swaps (setBatchMode already ran the synchronous
// refreshSelectionPaint pass; this nextTick re-mark catches the legend re-render).
// Both passes are idempotent reads of CURRENT state, so rapid flips converge on
// the last state — no stale markers (the L4 "glitchy" class).
watch([batchMode, isDark], () => { nextTick(() => markLegendBatchRows()) })
// Toggle a resolved territory (from a map feature OR a legend zone) in/out of the
// ordered selection. Shared by the map-tap and legend-row batch paths so both feed
// the SAME union (D-6). `t` is a { key, name, count, kind } identity.
function toggleTerritory(t) {
if (!t) return
const list = selectedTerritories.value
const idx = list.findIndex((x) => x.key === t.key)
if (idx >= 0) selectedTerritories.value = list.filter((_, i) => i !== idx) // toggle OFF
else selectedTerritories.value = [...list, t] // toggle ON
refreshSelectionPaint()
copyFlash.value = ''
}
// ── 09b shift-select R1.1 (g/h): selection COLOR ─────────────────────────────
// 8 preset swatches (Okabe–Ito palette — distinct + colorblind-aware, per the
// R1.1 decision row) + a custom hex input. Picking a color PREVIEW-recolors the
// selected territories on the map (the dedicated upg-sel-fill layer repaints —
// the base tapestry / zone-colors.json stay CLEAN until the operator applies the
// pasted payload via toolB, per the R1.1 "live recolor scope" default) and the
// clipboard payload gains the trailing " · #hex" that toolB parses.
const SWATCHES = ['#e69f00', '#56b4e9', '#009e73', '#f0e442', '#0072b2', '#d55e00', '#cc79a7', '#999999']
const selectedColor = ref('') // '' = no color picked (amber default style)
const hexDraft = ref('') // the custom-hex input model
function setSelectionColor(c) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(c || '').trim())
if (!m) return
const hex = '#' + m[1].toLowerCase()
selectedColor.value = selectedColor.value === hex ? '' : hex // re-pick toggles off
refreshSelectionPaint()
}
function onHexInput() {
if (/^#?[0-9a-fA-F]{6}$/.test(hexDraft.value.trim())) setSelectionColor(hexDraft.value)
}
// ── 09b-search-reuse-geocoder: search state ──────────────────────────────────
// The hand-rolled input/dropdown is GONE (§1a) — the SHARED <GeocoderComponent>
// owns the input now. `geocoderRef` is the component's exposed ref (mirrors
// research-map.vue:415). searchIndex (the headless matcher) is kept ONLY to back
// the window.__UPG_SEARCH__ test handle so the firefox gate can drive a
// deterministic select without keystroke timing; SEARCH_LIMIT caps it.
const geocoderRef = ref(null) // <GeocoderComponent> exposed ref
// Search parameterization — the SAME two props research-map uses to give each of
// its instances a distinct personality (research-map.vue:420-449). There the pair
// is computed from the active legend tab; this profile is single-taxonomy (no tab
// bar), so its context is constant.
//
// 'people-groups' orders the result groups blocs → clusters → peopleGroups first
// (useSearch.js ORDER_BY_CONTEXT), which matches this list's zone/territory/PG
// model. Without it the prop defaulted to '' → ALL_GROUPS ordering, i.e. the
// generic list rather than this map's own — the drift the rebuild spec flagged.
// The legend names the MAP, not just the taxonomy — with several maps in one app
// "Legend" alone does not say which one you are looking at.
// ★ NAME THE LEGEND'S DEPTH TABS. Without a `tabs` prop SemanticTreeLegend falls through to
// `src[i] ?? { id: 'gen'+(i+1), label: 'Generation '+(i+1) }` (SemanticTreeLegend.vue:157) — a
// placeholder that exists only so the tab strip has something to render. This bundle shipped with
// it, so the legend read "GENERATION 1 / 2 / 3" above a list of regions. A UUPG list has no notion
// of a generation, so the label described nothing and invited the reading that the tabs FILTER the
// map by some cohort. They do not: a tab is a DEPTH LEVEL of this tree, which is why selecting a
// node jumps to its depth (`activeTab = Math.min(sel.depth, …)`, same file:232).
//
// The three levels, read from data/legend-tree.js rather than guessed: regions are assembled at
// :302 by grouping leaves under regionFor(); zones are the middle nodes (Frontier Asia (uncovered)
// at :245, India at :199, Deaf (affinity bloc A017) at :159); leaves are the individual prayer
// zones each pin belongs to. Shape and `info` wording follow AFFINITY_TABS/REGIONS_TABS in
// country-posters/countries-map.vue:836, the established precedent — `info` renders both as the
// chip's title tooltip and as a popover (SemanticTreeLegend.vue:1125-1130).
//
// ★ THE IDS ARE DELIBERATELY BUNDLE-SCOPED, and that was MEASURED, not stylistic. tabLabel()
// (SemanticTreeLegend.vue:39) resolves `tabs.<id>` against the shared i18n catalog FIRST and only
// falls back to the English `label` when no entry exists — so a tab id is a GLOBAL namespace. The
// first version of this list used id:'region', and the legend rendered "WAGF Region" instead of
// "Region", because library/i18n/locales/en/tabs.json:17 already maps that key to the
// country-posters taxonomy. id:'group' collides the same way (→ "People Group", correct here only
// by coincidence — the catalog means the ROP-2.5 sense, not this bundle's). Prefixed ids own no
// catalog entry, so the labels below are what render. If these tabs are ever translated, add
// `upg100Region`/`upg100Zone`/`upg100Group` to tabs.json rather than reusing a generic key.
// ★ "Zone" RENAMED, ORDER DELIBERATELY UNCHANGED (2026-09-23).
// the agent: "the user does not know what zones means" — the thing the map paints IS
// a UPG-100 list (27 of them); "zone" was our internal word leaking into the UI.
// So the LABEL now names what it shows. Region stays as what it actually is: a way
// to SORT those lists by world area when you want that.
//
// ⚠ DO NOT REORDER THESE TO MAKE THE LIST TAB OPEN FIRST. Tab INDEX is coupled to
// tree DEPTH in the shared legend: `activeTab = Math.min(sel.depth, …)` and again
// on breadcrumb (SemanticTreeLegend ~231 and ~418). The tree nests region(0) →
// zone(1) → group(2), so moving zone to index 0 would make selecting a zone jump
// to the Region tab. Seven profiles share that component.
// Opening on the list tab is done the way the codebase already provides for it:
// `:default-tab="1"` at the call site — the prop country-posters added for exactly
// this ("it opens to the country's semantic level in the legend").
//
// NOTE the warning above: these prefixed ids own no catalog entry, so THESE labels
// are what render. If the packet is ever translated, add upg100Zone / upg100Region
// / upg100Group to tabs.json rather than reusing a generic key.
const LEGEND_TABS = [
{ id: 'upg100Region', label: 'Region',
info: 'Region — sort the UPG-100 lists by world area (Africa, India, Middle East, Deaf, …). Counts are the UUPGs and population summed across every list beneath it.' },
{ id: 'upg100Zone', label: 'UPG-100 Lists',
info: 'UPG-100 Lists — each one is a prayer list of the unreached people groups in an area, and is what the map paints and colours. Every list prints as its own packet.' },
// ★ THE 'People Group' TAB WAS REMOVED BECAUSE IT COULD NEVER RENDER ANYTHING — measured, not
// a matter of taste. SemanticTreeLegend renders tab N as collectAtDepth(nodes, N), so a tab's
// index IS a tree depth. This legend's tree is exactly TWO levels deep: 9 regions -> 28 lists.
// MEASURED 2026-09-25 by expanding every caret on the Region tab — 37 rows, 9 at indent 0 and
// 28 at indent 1, deepest level 1. There is no level 2, so tab index 2 resolved to an empty set
// and the panel rendered its "No data at this level" empty state with the tab ACTIVE.
// The agent said "it doesn't need to have a People Group tab inside the legend"; the reason is
// that clicking it showed nothing at all.
// ⚠ IF PEOPLE-GROUP ROWS ARE EVER WANTED BACK, a third tab is not the mechanism — the tree would
// need a third level (list -> group). Re-adding the tab without deepening the data reproduces
// exactly this empty state.
]
// The generation the legend OPENS on: index 1 = the UPG-100 Lists tab. A reader
// should meet the lists first and reach for Region only to sort them.
const LEGEND_DEFAULT_TAB = 1
const geocoderContext = 'people-groups'
let searchIndex = [] // [{ label, kind, target, _hay }] (gate handle)
const SEARCH_LIMIT = 8
let map = null
let tapestry = null // markRaw FeatureCollection (all 26 zones)
let leafZoneList = [] // flat [{ id, zoneKey, color, selector }]
let matchColorExpr = null // cached ['match', …] fill-color expression
let _detachEdges = null // useEdgeScrollAffordance teardown
let addPinLayers = null // set in onMounted (closes over pinFC); re-used by theme re-add
// Config-only, fail-closed: base comes solely from config, NO default host.
const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|| import.meta.env.VITE_API_BASE_URL
|| ''
// Prayer-packet printing (legend Export > Prayer packet). The ACTION lives in the
// shared usePosterPrints composable — it prints through a hidden same-page iframe,
// so the dialog opens in this tab rather than a popup a browser may block. The
// packet's locale follows the app's, so a reader gets it in their own language.
const { locale: packetLocale, t } = useI18n()
// ⚠ IT WAS A HARDCODED ENGLISH LITERAL, and SemanticTreeLegend passes `title` through RAW —
// the caller owns localizing it. MEASURED 2026-09-25 on the live Chinese render: the legend
// header read "My UPG 100 List — Legend" in full English inside an otherwise localized panel,
// beside translated tabs (区域 · UPG-100 名单) and a translated column header (人口).
// The agent's gift policy is explicit — "BEAUTIFUL: no clipped text, no placeholders, no English
// leaking" — and an English title on the mobile showcase is exactly that.
// ★ THE HOUSE STYLE FOR A PROPER NOUN INSIDE A TRANSLATED PHRASE ALREADY EXISTS in this catalog:
// "Doxa Regions" renders as "Doxa 区域" / "مناطق Doxa" / "Регионы Doxa" — the identifier stays
// Latin, the frame translates. "UPG 100" is treated the same way rather than transliterated, so
// this follows a convention demonstrated across 11 locales instead of inventing terminology.
// COMPUTED, not a constant: a locale change must move it. A `const` captured at setup would keep
// the mounting language forever, which is the subtler half of the same bug.
const LEGEND_TITLE = computed(() => t('legend.title.upg100List'))
// ★ THE SEARCH PLACEHOLDER WAS THE LAST ENGLISH STRING ON THE ARABIC SCREEN. repo's ?lang=ar
// screenshot was clean except for this one hardcoded literal.
// ⚠ A computed, and declared HERE rather than beside geocoderContext, because t() is not
// initialised until line 359 — a const at 339 would capture the fallback and never re-evaluate
// when the locale changes.
// NO NEW TERMINOLOGY: composed from wording this product already ships and has already approved —
// search.kinds.peopleGroup for the group, tabs.upg100Zone for what this plugin calls a "zone"
// (its legend names them "UPG-100 Lists" / "قوائم UPG-100" / "UPG-100 名单"), and
// search.kinds.region for the territory. That is why this could be translated and the UUPGs
// column header could not: there, no source existed for the acronym.
const geocoderPlaceholder = computed(() => t('search.upg100Placeholder'))
const DEAF_COUNT = 123
// Shadow-root CSS. (No backticks inside this string — a stray backtick closes the
// JS template literal and silently blanks the whole stylesheet. Burned us 3×.)
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// The shared gray edge-strip affordance (lifted out of useMapInstance 2026-07-30 so
// non-useMapInstance profiles like this one can use it).
import { useEdgeScrollAffordance } from '@map/composables/useEdgeScrollAffordance.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
useShadowStyles(
'.upg-root{position:absolute;inset:0;background:#0a1410;color:#e5e7eb;' +
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" +
'.upg-map{position:absolute;inset:0}' +
// P3-1 graceful degrade: the map-cell error notice. Centred in the map cell and
// pointer-events:none so it never swallows clicks meant for the legend/toolbar
// overlays that sit above it. z-index stays BELOW the legend (.stl-panel) and the
// toolbar so the surviving UI always wins the stacking contest — the point of the
// fix is that the list stays usable while only the map is gone.
'.upg-map-err{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);' +
'z-index:1;pointer-events:none;text-align:center;padding:0 24px;' +
"font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#9aa7b8}" +
'.upg-map-err strong{display:block;font-size:14px;color:#cbd5e1;margin-bottom:4px}' +
// Edge scroll-affordance hit-boxes — same geometry research-map uses. They are
// INVISIBLE by default (background:transparent); the shared composable paints the
// gray gradient and toggles opacity. touch-action:pan-y IS the mechanism that
// lets a vertical drag scroll the PAGE while Mapbox keeps the centre.
'.map-scroll-edge{position:absolute;top:0;bottom:0;width:40px;z-index:2;' +
'touch-action:pan-y;pointer-events:auto;background:transparent}' +
'.map-scroll-edge--left{left:0}' +
'.map-scroll-edge--right{right:0}' +
// Desktop panel vs mobile bottom-sheet — exactly one is visible. Breakpoint
// matches research-map's .rm-legend-*-slot (767px) so the whole map stack
// flips at the same width instead of drifting per profile.
// ★ The search bar above the legend was REMOVED from this profile, so the legend rises into the
// vacated space (the agent 2026-09-23: "the search bar is removed... the legend needs to be moved
// up so that there's not a gap where the search bar used to be"). --stl-top is the shared
// SemanticTreeLegend's offset-from-map-top; 8px matches its own left inset. Other profiles keep
// the 60px default because they still have their search bar.
'.upg-legend-desktop-slot{--stl-top:8px}' +
'.upg-legend-mobile-slot{display:none}' +
'@media(max-width:767px){' +
'.upg-legend-mobile-slot{display:block}' +
'.upg-legend-desktop-slot{display:none}' +
// The bottom-sheet owns the people-group card on mobile — see the
// .upg-detail-desktop-slot comment in the template.
'.upg-detail-desktop-slot{display:none}}' +
'.upg-status{position:absolute;bottom:10px;right:10px;z-index:5;' +
'font:10px/1 ui-monospace,monospace;color:#9aa7b8;background:rgba(10,14,21,.82);' +
'padding:6px 10px;border-radius:8px}' +
// slice 13 §1c: detail-panel wrapper. The SemanticTreeLegend lives top-LEFT
// (.stl-panel left:8px) in THIS profile and the MapToolbar floats top-RIGHT;
// so the detail panel slides in from the RIGHT edge, starting below the
// toolbar (top:60px) so it clears the control rail (D-1 adapted: the QA
// assumed legend top-right, but here it is top-left — right-side avoids the
// legend collision and the controls). PeopleGroupDetail brings its own inner
// CSS via useShadowStyles; this wrapper only positions + frames it.
'.upg-detail-panel{position:absolute;top:60px;right:8px;bottom:10px;width:340px;' +
'max-width:calc(100vw - 16px);z-index:6;background:#fff;border-radius:12px;' +
'box-shadow:0 8px 28px rgba(0,0,0,.45);overflow:hidden;display:flex;' +
'flex-direction:column;animation:upgPanelIn .18s ease}' +
'.upg-detail-panel.dark{background:#1a1a2e}' +
'@keyframes upgPanelIn{from{transform:translateX(16px);opacity:0}' +
'to{transform:translateX(0);opacity:1}}' +
'@media(max-width:480px){.upg-detail-panel{top:auto;left:8px;right:8px;' +
'width:auto;height:55vh}}' +
// ── 09b shift-select tool: the land-division HUD (sits ABOVE the status badge,
// bottom-right). Shows "N territories · TOTAL UUPG" live + a Copy/Clear bar that
// only appears when something is selected. (No backticks anywhere in this string
// — a stray one closes the JS template literal and blanks the whole stylesheet.)
'.upg-hud{position:absolute;bottom:34px;right:10px;z-index:6;min-width:150px;' +
'font:11px/1.35 ui-monospace,monospace;color:#e5e7eb;' +
'background:rgba(10,20,16,.92);border:1px solid rgba(110,231,183,.35);' +
'border-radius:10px;padding:8px 10px;box-shadow:0 4px 16px rgba(0,0,0,.4)}' +
'.upg-hud-head{display:flex;align-items:baseline;gap:6px;white-space:nowrap}' +
'.upg-hud-total{font-size:15px;font-weight:700;color:#6ee7b7}' +
'.upg-hud-band{margin-left:auto;font-size:9px;color:#9aa7b8}' +
'.upg-hud-band.ok{color:#6ee7b7}' +
'.upg-hud-names{margin-top:5px;max-height:84px;overflow:auto;font-size:10px;' +
'color:#cbd5e1;line-height:1.45;word-break:break-word}' +
'.upg-hud-actions{display:flex;gap:6px;margin-top:7px}' +
// R1.2 mobile sizing: Copy/Clear are ≥40px tap targets (was 6px padding ~24px).
'.upg-hud-btn{flex:1;cursor:pointer;border:1px solid rgba(110,231,183,.4);' +
'background:rgba(16,185,129,.18);color:#d1fae5;font:12px/1 ui-monospace,monospace;' +
'padding:0 10px;min-height:40px;border-radius:8px}' +
'.upg-hud-btn:hover{background:rgba(16,185,129,.32)}' +
// R1.4 (L4): the full-width HUD .upg-batch-btn is GONE — the map-rail
// .upg-rail-batch (below) is the SINGLE batch entry point, and the whole HUD
// only renders while batch mode is ON (progressive disclosure).
'.upg-hud-btn.clear{background:rgba(148,163,184,.12);border-color:rgba(148,163,184,.4);' +
'color:#cbd5e1}' +
'.upg-hud-flash{margin-top:5px;font-size:9px;color:#6ee7b7;min-height:11px}' +
'.upg-hud-hint{margin-top:4px;font-size:9px;color:#64748b}' +
// R1.1 (g): the color-picker row — 8 preset swatches + a custom hex input.
// (Still no backticks anywhere in this string — the burned-3x rule.)
'.upg-hud-swatches{display:flex;gap:6px;margin-top:7px;align-items:center;flex-wrap:wrap}' +
// R1.2 mobile sizing: swatches are ≥40px tap targets (was 18px — too small for
// touch). The colored chip stays visually compact via an inset box-shadow ring
// while the hit-area fills the 40px button.
'.upg-swatch{width:40px;height:40px;border-radius:9px;cursor:pointer;padding:0;' +
'border:1px solid rgba(255,255,255,.3)}' +
'.upg-swatch.active{outline:2px solid #fff;outline-offset:1px}' +
'.upg-hex{width:84px;min-height:40px;background:rgba(148,163,184,.12);' +
'border:1px solid rgba(148,163,184,.4);color:#cbd5e1;' +
'font:12px/1 ui-monospace,monospace;padding:0 8px;border-radius:8px}' +
// R1.2 mobile sizing: at ≤480px the HUD spans the bottom edge (above the status
// badge) so the Batch button + swatches + Copy never overlap the legend/toolbar.
'@media(max-width:480px){.upg-hud{left:8px;right:8px;min-width:0;bottom:30px;' +
'max-height:48vh;overflow:auto}}' +
// ── Batch Select toggle — the ONLY local rule left is the emerald ON state ───
// ARCHIVED 2026-07-30: this profile used to hand-roll the whole button skin
// (~14 rules duplicating .mcb: size, radius, shadow, hover, active-press, dark).
// That duplicate is DELETED — the button is now a shared <MapControlButton>, so
// size/shape/hover/dark all come from the library and it lines up with its
// neighbours. Two bugs came from the duplicate:
// 1. it declared min-width/min-height:40px, a FLOOR the shared 36px mobile rule
// could not override, so on mobile every other control shrank and this one
// did not;
// 2. it was not a .mcb, so MapToolbar's own '.map-toolbar .mcb{width:32px}'
// slimming never applied to it — the rail slims to 32px and this button
// stayed 40px, which is the "larger + not in line + wrong spacing" report.
// Kept: the emerald ON state. The shared .mcb.active is a blue tint; batch mode
// is a MODE, not a momentary toggle, so it wants a louder signal. Scoped to this
// button only, and it overrides nothing structural.
'.map-toolbar .mcb.upg-batch-on{background:#10b981!important;color:#04241a!important;' +
'box-shadow:0 0 0 2px rgba(52,211,153,.45),0 1px 4px rgba(0,0,0,0.2)!important}' +
'.map-toolbar .mcb.upg-batch-on.dark{border-color:#34d399!important}' +
// ── R1.3 (j): legend MULTI-SELECT marker (batch mode). The SHARED legend only
// paints ONE .selected row (its internal single selectedId). In batch mode the
// design note selects 2+ rows, so the profile marks EVERY legend row that maps to a
// selected zone with .upg-batch-row (applied profile-side by markLegendBatchRows
// — we never edit the shared component). Emerald left-rail + tint so multiple
// rows clearly read as selected at once; the dot gets an emerald ring.
'.upg-batch-row{position:relative;background:rgba(16,185,129,.16)!important;' +
'opacity:1!important;box-shadow:inset 3px 0 0 0 #10b981}' +
'.upg-batch-row .stl-name{color:#065f46!important;font-weight:700}' +
'.stl-panel[data-theme="dark"] .upg-batch-row .stl-name,' +
'.upg-batch-row.dark .stl-name{color:#6ee7b7!important}' +
'.upg-batch-row .stl-dot{box-shadow:0 0 0 2px #10b981}' +
// ── 09b-search-reuse-geocoder §1(b): position the SHARED mapbox-gl-geocoder
// ABOVE the legend. The component addControl()s itself into the map's top-left
// ctrl stack; the legend (.stl-panel, also top-left) is positioned lower (top
// offset + lower z) so the geocoder pill sits ABOVE it. This clones research-
// map's geocoder column rules (research-map.vue:183-228) — the geocoder pill +
// suggestions + dark mode — so tabs render visually identical. (No backticks in
// this string — a stray one closes the JS template literal and blanks the CSS.)
// The top-left ctrl group holds the geocoder; give it width + a top offset and
// push the legend panel DOWN so it clears the geocoder pill.
// Size the CONTAINER, never the pill — the pill fills it via the shared
// 'geocoder-skin' rule in GeocoderComponent.vue, and the shared skin also owns
// the mobile edge-to-edge rule. Sizing the pill directly (what this profile used
// to do: width:min(360px,calc(100vw - 96px)) with an unsized container) is why it
// rendered narrower than research-map and kept a 96px dead margin on phones.
// Colour / radius / shadow / focus-ring / font-size are the library's too.
// Do NOT re-add skin or fill rules here — change the library.
'.mapboxgl-ctrl-top-left{top:12px!important;left:8px!important;width:380px!important;' +
'max-width:380px!important;z-index:1200!important}' +
'.mapboxgl-ctrl-top-left .mapboxgl-ctrl{margin:0!important;float:none!important}' +
// Push the legend panel BELOW the geocoder pill (legend is top-left here too) —
// ★ BUT ONLY IF THE PILL ACTUALLY RENDERED (the agent 2026-09-23, reported repeatedly: "the search
// bar is removed, so the legend needs to move up — there's a gap where the search bar used to
// be"). MEASURED in both hosts with a real token: the standalone leaf mounts the geocoder
// (top 12, h 35) and the legend belongs at 58; the cap-5 shell mounts NO geocoder at all, and
// an unconditional 58px stranded the legend below a pill that was not there — the dead gap the
// the agent kept reporting. One host cannot be fixed by breaking the other, so the offset is
// conditional on the pill EXISTING: default raised to 8px (matching .stl-panel's own left
// inset), pushed down only when a rendered geocoder is actually in the tree.
// ★ SET --stl-top, DO NOT SET top DIRECTLY. The library caps the panel at
// calc(100% - var(--stl-top, 60px) - 12px) (SemanticTreeLegend.vue:605), so the offset and the
// height cap MUST come from one value. Overriding top alone moved the panel down while the cap
// kept subtracting the OLD offset, and the two silently disagreed.
// MEASURED 2026-09-24, upg-100-list hosted in a 420px-tall frame at 390px wide: top resolved to
// 58px while max-height resolved to calc(100% - 20px), so the panel ran to 458px in a 420px map
// and HUNG 38px PAST THE BOTTOM OF THE MAP — exactly 58 - 20. Same 38px at a 560px frame, because
// the error is the offset mismatch, not the map's size. the agent 2026-09-24: "the upg 100 list
// legend on the map is overflowing beyond the height of the map".
// ⚠ WHY THIS IS INVISIBLE ON EVERY PUBLISHED LEAF: a standalone leaf gives the map the FULL
// viewport, and a panel that overflows a full-height map is still inside the screen. It only
// shows when a host makes the map SHORTER than the viewport, which is how the element is really
// embedded. Verify in a constrained host, never on the bare leaf.
'.stl-panel{--stl-top:8px!important}' +
'.upg-root:has(.mapboxgl-ctrl-geocoder) .stl-panel{--stl-top:58px!important}' +
// Geocoder suggestion metadata layout (clone — bold field labels for PG rows).
'.dg-main{font:13px/1.3 ui-monospace,monospace}' +
'.dg-meta{margin-top:2px;font-size:11px;color:#475569;display:flex;flex-wrap:wrap;gap:8px}' +
'.dg-field strong{font-weight:700}' +
'.dg-section-header-item{padding:4px 8px;font:10px/1.2 ui-monospace,monospace;' +
'letter-spacing:.06em;text-transform:uppercase;color:#94a3b8;pointer-events:none}' +
// ARCHIVED 2026-07-30 — the local dark-mode geocoder skin lived here. It is now
// in GeocoderComponent.vue ('geocoder-skin'), which honours BOTH the
// `geocoder-dark` class (set from the isDark prop) and a `.dsm-dark` ancestor,
// so this map gets the same sage pill every other map does. The archived values
// were: background #0a1410, border rgba(110,231,183,.30) mint, input #e5e7eb —
// a palette no other profile used. Kept in this comment as the record of what
// was replaced; do not restore them.
''
)
// ── tapestry paint: ONE source, ONE match expression ────────────────────────
function buildMatchColorExpr(zones) {
// ['match', ['get','zoneKey'], key1,color1, key2,color2, …, fallback]
// slice 09b-9 §3 (L18/L29 FIX): each arm's color now resolves through
// colorForZone(zoneKey, leafColor) — the SAME source the strategy
// lives in (zone-colors.json) — so the IDLE tapestry paints the region-inherited
// shades (no rainbow, no click needed). z.color (the deterministic leafColor HSL)
// is the FALLBACK when a zone has no override yet. deaf (#18ffff) is one of those
// overrides, so its handling is preserved through the same path.
const arms = []
for (const z of zones) { arms.push(z.zoneKey, colorForZone(z.zoneKey, z.color)) }
// F2-D3: visible neutral fallback (#9ca3af) — the old #334155 dark slate was
// invisible on the dark-v11 basemap and made unmatched pins/fills vanish.
return ['match', ['get', 'zoneKey'], ...arms, '#9ca3af']
}
function ensureLayers() {
if (map.getSource('tapestry')) return
map.addSource('tapestry', { type: 'geojson', data: tapestry })
// 09b light-default L-D2: the neon cyber palette runs hot on white. ensureLayers
// is called on the initial draw AND on every post-setStyle theme re-add, so we
// read isDark.value HERE (JS state, not a map expression) to pick per-theme
// opacity. DARK is unchanged (the showcase): deaf 0.45 / others 0.65. LIGHT is
// lowered (deaf 0.30 / others 0.45) so the neon reads as an accent, not garish —
// the bright tapestry-LINE outline carries the glow on light either way.
const deafOpacity = isDark.value ? 0.45 : 0.30
const othersOpacity = isDark.value ? 0.65 : 0.45
map.addLayer({
id: 'tapestry-fill',
type: 'fill',
source: 'tapestry',
paint: {
'fill-color': matchColorExpr,
'fill-opacity': ['case', ['==', ['get', 'zoneKey'], 'deaf'], deafOpacity, othersOpacity],
},
})
map.addLayer({
id: 'tapestry-line',
type: 'line',
source: 'tapestry',
// F2-D2: brighter, thicker outline gives contrast on dark without washing out light.
paint: { 'line-color': matchColorExpr, 'line-width': 1.2, 'line-opacity': 0.9 },
})
// ★ THE REAL READINESS SIGNAL. onLegendSelect() returns early unless map.getLayer('tapestry-fill')
// exists, so a "Show on map" request that arrives before this function runs cannot hold a
// selection however correctly it was delivered. This is the third layer of the same defect --
// apply once against unready state, then discard -- and this is the point where the state is
// finally ready. Drains any request retained by applySelectZone().
if (_pendingZoneId) {
const id = _pendingZoneId
nextTick(() => { if (_pendingZoneId === id) applySelectZone(id) })
}
}
// ── 09b shift-select tool: selection highlight layers (D-2) ──────────────────
// A DEDICATED fill+line pair on the SAME tapestry source, painted only for the
// currently-selected territories — visually DISTINCT from the zone-isolate style
// (which re-filters the base tapestry-fill). Bright amber outline + light tint so
// the operator sees the running land-division grow. The match is by shapeName
// (ADM1) OR iso3 (ADM0) so countries and admins both highlight from one filter.
function ensureSelectionLayers() {
if (!map || !map.getSource('tapestry')) return
if (map.getLayer('upg-sel-fill')) return
// start filtered to nothing (false) until a selection exists
map.addLayer({
id: 'upg-sel-fill', type: 'fill', source: 'tapestry',
filter: ['boolean', false],
paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0.28 },
})
map.addLayer({
id: 'upg-sel-line', type: 'line', source: 'tapestry',
filter: ['boolean', false],
paint: { 'line-color': '#fbbf24', 'line-width': 2.4, 'line-opacity': 0.95 },
})
}
// Build the Mapbox filter expr matching every selected territory by its identity
// prop (shapeName for adm1, iso3 for adm0). Empty selection → ['boolean', false].
function selectionFilterExpr() {
const adm1 = [], adm0 = [], zones = []
for (const t of selectedTerritories.value) {
if (t.kind === 'adm1') adm1.push(t.name)
else if (t.kind === 'adm0') adm0.push(t.key.slice(5)) // strip 'adm0:'
else if (t.kind === 'zone') zones.push(t.key.slice(5)) // strip 'zone:' → zoneKey
}
const parts = []
if (adm1.length) parts.push(['in', ['get', 'shapeName'], ['literal', adm1]])
if (adm0.length) parts.push(['in', ['get', 'iso3'], ['literal', adm0]])
// R1.2 D-6: legend rows ride as kind 'zone' → highlight every tapestry feature
// whose zoneKey is in the selected-zone set (one union with map territories).
if (zones.length) parts.push(['in', ['get', 'zoneKey'], ['literal', zones]])
if (!parts.length) return ['boolean', false]
return parts.length === 1 ? parts[0] : ['any', ...parts]
}
// Re-apply the selection filter to the highlight layers (idempotent; safe to call
// after every toggle AND after a setStyle theme re-add wipes the layers).
// R1.1 (g): when the operator has picked a color, the selection layers PAINT that
// color (live preview recolor — fill opacity rises so the hue actually reads as
// the territory's new color); with no pick they keep the amber selection style.
function refreshSelectionPaint() {
// R1.3 (j) / R1.4: legend markers FIRST, outside the map-layer guard — the
// legend lives outside the map, so markers stay honest even in the post-
// setStyle window where upg-sel-fill doesn't exist yet (paintAll re-runs the
// layer half on style.load). Every union/mode mutation flows through here.
markLegendBatchRows()
if (!map || !map.getLayer('upg-sel-fill')) return
// R1.4 progressive disclosure: the amber/preview map highlight is batch chrome
// — out of batch mode it filters to nothing (the selection STATE is kept, D-7).
const expr = batchMode.value ? selectionFilterExpr() : ['boolean', false]
map.setFilter('upg-sel-fill', expr)
map.setFilter('upg-sel-line', expr)
const c = selectedColor.value
map.setPaintProperty('upg-sel-fill', 'fill-color', c || '#f59e0b')
map.setPaintProperty('upg-sel-fill', 'fill-opacity', c ? 0.55 : 0.28)
map.setPaintProperty('upg-sel-line', 'line-color', c || '#fbbf24')
}
// ── R1.2 PIVOT: batch-MODE click handler on tapestry-fill (NO modifier keys) ──
// batch mode ON → a PLAIN tap TOGGLES the clicked territory in selectedTerritories
// (a″ — the mobile gesture; identical for mouse + touch).
// batch mode OFF → a PLAIN tap on the map fill clears any batch selection (§1e
// click-empty reset); the legend row-click still drives single-
// zone isolate independently (a‴). NO shiftKey/ctrlKey/metaKey
// branch — touch devices have no modifier keys (D-8).
// The resolver maps the clicked feature → { key, name, count } regardless of ADM0/ADM1.
let _tapestrySelectHandler = null
let _tapestryCursorBound = false
function attachTapestrySelect() {
if (!map || !map.getLayer('tapestry-fill')) return
// Re-bind cleanly across setStyle re-adds: drop the prior layer-scoped handler
// first so a dark/light toggle never stacks duplicate click listeners (which
// would toggle a territory twice and cancel the selection).
if (_tapestrySelectHandler) {
try { map.off('click', 'tapestry-fill', _tapestrySelectHandler) } catch (_) {}
}
_tapestrySelectHandler = (e) => {
const f = e && e.features && e.features[0]
if (!f) return
if (batchMode.value) {
// BATCH-SELECT (toggle) — plain tap, no modifier key required.
const t = resolveTerritory(f.properties, { nameMap: countryNameMap })
if (!t) { console.warn('[batch-select] clicked feature has no shapeName/iso3', f.properties); return }
if (t.count == null) console.warn('[batch-select] no UUPG count for territory', t.name, f.properties)
toggleTerritory(t)
}
// R1.4: OUT of batch mode a plain map-fill tap is a NO-OP for the selection.
// (It used to clearSelection() — that silently nuked the D-7 kept selection
// the moment the operator tapped the map after "Done", one of the L4 glitches.)
// The legend row-click still drives single-zone isolate independently (a‴);
// Clear inside the batch HUD is the only way to empty the selection.
}
map.on('click', 'tapestry-fill', _tapestrySelectHandler)
if (!_tapestryCursorBound) {
// Hover affordance only signals "tappable to add" while batch mode is ON.
map.on('mouseenter', 'tapestry-fill', () => {
if (map) map.getCanvas().style.cursor = batchMode.value ? 'crosshair' : ''
})
map.on('mouseleave', 'tapestry-fill', () => {
if (map) map.getCanvas().style.cursor = ''
})
_tapestryCursorBound = true
}
}
// ── 09b §1(e): clear the batch selection ─────────────────────────────────────
function clearSelection() {
selectedTerritories.value = []
copyFlash.value = ''
selectedColor.value = '' // R1.1: a fresh selection starts color-less (clean state)
hexDraft.value = ''
refreshSelectionPaint()
}
// ── 09b §1(d): copy the selection to the clipboard (D-1 format) ──────────────
// "Name1, Name2, … — {total} UUPG" + a 2nd line per-territory breakdown.
// navigator.clipboard on the click gesture; textarea+execCommand fallback for a
// non-secure context (where navigator.clipboard is undefined). Exposed on window
// for the firefox gate to read back the exact string the handler built.
async function copySelection() {
if (!selectedTerritories.value.length) return
// R1.1 (h): the payload ends with " · #hex" when a color is picked — the exact
// string toolB parses to regroup+recolor in one command.
const text = formatClipboard(selectedTerritories.value, { color: selectedColor.value })
if (typeof window !== 'undefined') window.__UPG_LAST_CLIPBOARD__ = text
let ok = false
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
ok = true
} else {
ok = legacyCopy(text)
}
} catch (err) {
ok = legacyCopy(text) // secure-context / permission failure → fallback
}
copyFlash.value = ok ? 'Copied!' : 'Copy failed — press Ctrl/Cmd-C'
setTimeout(() => { if (copyFlash.value.startsWith('Copied')) copyFlash.value = '' }, 1800)
}
// textarea + execCommand('copy') fallback (non-secure context, no clipboard API).
function legacyCopy(text) {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.top = '0'
document.body.appendChild(ta)
ta.focus(); ta.select()
const ok = document.execCommand && document.execCommand('copy')
document.body.removeChild(ta)
return !!ok
} catch (_) { return false }
}
// ── R1.2 PIVOT (D-8): the Ctrl/Cmd-C + Escape keyboard handler is REMOVED. The
// the brief explicitly asked to drop modifier-key gestures for mobile parity — Copy
// + Clear are the on-screen HUD buttons (≥40px tap targets) and are the ONLY
// selection-action surface now. No code path depends on a keyboard modifier.
// ── 09b-search-bar §1(a/c): build the unified index once records are loaded ──
// PG rows come from the LIVE records (slug+name+lat/lng) — the same record set
// useGeoSearch consumes; zone rows from the legend leafZones; territory rows
// from admin-counts (ADM1) + ISO3 names (ADM0 countries). countryNameMap (built
// from the live records) gives the exact country label the legend shows.
function buildSearch(records) {
// (i) Feed the SHARED GeocoderComponent: unify PG + zone + territory into ONE
// record list and cache it under our source so useGeoSearch (inside the
// component) indexes it. Zones/territories ride as synthetic records with a
// centroid (from boundsFor) + a `__upgKind` discriminator. centroidFor lets
// the data module stay DOM/Mapbox-free (pure fns) — the profile owns geometry.
if (dataStore && typeof dataStore.cacheSourceData === 'function') {
const feed = buildGeocoderFeed({
records,
leafZones: leafZoneList,
adm1Counts: ADM1_COUNTS,
iso3Names: ISO3_NAME,
countryNameMap,
centroidFor: (pred) => {
const b = boundsFor(pred)
if (!b) return null
return [(b[0][0] + b[1][0]) / 2, (b[0][1] + b[1][1]) / 2]
},
})
// markRaw inside cacheSourceData keeps this large array out of reactivity.
dataStore.cacheSourceData(GEOCODER_SOURCE, { normalizedData: feed })
}
// (ii) Build the headless matcher index — TEST-ONLY (backs window.__UPG_SEARCH__
// so the firefox gate drives a deterministic select). The live search UI is
// the shared GeocoderComponent; this is just a programmatic shim for proofs.
searchIndex = buildSearchIndex({
records,
leafZones: leafZoneList,
adm1Counts: ADM1_COUNTS,
iso3Names: ISO3_NAME,
countryNameMap,
})
if (typeof window !== 'undefined') {
window.__UPG_SEARCH__ = {
size: () => searchIndex.length,
counts: () => {
const c = { pg: 0, zone: 0, territory: 0 }
for (const e of searchIndex) c[e.kind] = (c[e.kind] || 0) + 1
return c
},
query: (q) => matchSearch(searchIndex, q, { limit: SEARCH_LIMIT }).map((e) => ({ label: e.label, kind: e.kind, target: e.target })),
// drive a real select from the gate (PG / zone / territory) to prove fly-to.
// Routes through the SAME flyToTarget the geocoder result handler uses.
select: (q) => {
const r = matchSearch(searchIndex, q, { limit: SEARCH_LIMIT })[0]
if (!r) return null
flyToTarget(r.target)
return { label: r.label, kind: r.kind, target: r.target }
},
// P0 triage handles (LESSON-03): prove the geocoder feed reached the
// dataStore AND the live localGeocoder closure matches it. Test-only.
feedSize: () => {
const s = dataStore && dataStore.sources && dataStore.sources[GEOCODER_SOURCE]
return (s && Array.isArray(s.features) && s.features.length) || 0
},
localGeocoder: (q) => {
const g = geocoderRef.value && geocoderRef.value.geocoder
const fn = g && g.options && g.options.localGeocoder
if (!fn) return null
return fn(q).map((f) => f && f.place_name)
},
}
}
}
// ── 09b-search-reuse-geocoder §1(c): the SHARED GeocoderComponent emits
// `people-group-result` for any selected row (our feed is all place_type
// 'people-group'). Branch on the synthetic `__upgKind` we threaded through the
// record → properties so the SAME fly handlers fire as the old dropdown:
// no kind / 'pg' → flyTo + open detail · zone → fitBounds · terr → fitBounds.
// This mirrors research-map.vue:1410 (onGeocoderPeopleGroupResult) but keeps THIS
// profile's zone/territory targets (which the DOXA schema can't express).
function onGeocoderResult(feature) {
const props = (feature && feature.properties) || {}
const kind = props.__upgKind
if (kind === 'zone') {
flyToZone(props.__zoneId)
} else if (kind === 'territory') {
flyToTerritory(props.__shapeName)
} else {
// Real PG: fly to its point + open the shared PeopleGroupDetail (slice-13).
const geom = feature && feature.geometry
const c = geom && Array.isArray(geom.coordinates) ? geom.coordinates : null
if (c && Number.isFinite(c[0]) && Number.isFinite(c[1]) && map) {
map.flyTo({ center: [c[0], c[1]], zoom: 6, duration: 800 })
}
openPgDetail(props.slug)
}
}
// AGGREGATE picks (country / language-family / language / dialect / religion).
// The shared geocoder searches ALL DOXA categories, not just this list's pins, so
// these arrive even though the profile's own feed only supplies PG/zone/territory.
// NOTE the payload is {kind,label,count,memberIds} — a plain object, NOT a GeoJSON
// feature — so it must not be routed into onGeocoderResult, which reads
// feature.properties/.geometry and would silently fall through to the PG branch.
//
// This profile is a CURATED 100-list, not the full research surface: it has no
// legend tab to swap and no aggregate polygons to outline. So the honest behaviour
// is to frame whichever of THIS list's pins belong to the picked aggregate, and do
// nothing when none do (rather than fake a match or blank the map).
function onGeocoderAggregate(agg) {
if (!agg || !map) return
const ids = new Set(agg.memberIds || [])
if (!ids.size) return
// Frame the pins of THIS list that belong to the picked aggregate. Reads the
// live 'upg' source rather than a module-scoped copy — pinFeatures is local to
// the loader closure, and querySourceFeatures is the canonical way to ask the
// map what it currently holds.
let members = []
try {
members = map.querySourceFeatures('upg').filter((f) => {
const p = f.properties || {}
return ids.has(p.slug) || ids.has(p.id) || ids.has(p.peopleGroupId)
})
} catch { return } // source not added yet — no-op
if (!members.length) return // nothing of this list matched → leave the view alone
let minX = 180, minY = 90, maxX = -180, maxY = -90, hit = false
for (const f of members) {
const c = f.geometry && f.geometry.coordinates
if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) continue
if (c[0] < minX) minX = c[0]; if (c[0] > maxX) maxX = c[0]
if (c[1] < minY) minY = c[1]; if (c[1] > maxY) maxY = c[1]
hit = true
}
if (hit) map.fitBounds([[minX, minY], [maxX, maxY]], { padding: 60, duration: 800, maxZoom: 6 })
}
// X (clear) — the spec's Bug 1: the geocoder's X emits but resets NOTHING, so the
// parent owns the reset. The old clone drifted into a partial reset that orphaned
// state; keep this one aligned with what THIS profile actually sets, namely the
// isolate filter and the detail panel.
function onGeocoderClear() {
onLegendSelect(null) // canonical un-isolate (restores 26-zone tapestry)
if (uiStore && typeof uiStore.selectPeopleGroup === 'function') {
uiStore.selectPeopleGroup(null) // close the detail panel / bottom-sheet card
}
}
// Shared select path for the test handle — mirrors onGeocoderResult's routing by
// the headless target shape ({ type, slug/zoneId/shapeName }).
function flyToTarget(t) {
if (!t || !map) return
if (t.type === 'pg') {
if (Number.isFinite(t.lng) && Number.isFinite(t.lat)) {
map.flyTo({ center: [t.lng, t.lat], zoom: 6, duration: 800 })
}
openPgDetail(t.slug)
} else if (t.type === 'zone') {
flyToZone(t.zoneId)
} else if (t.type === 'territory') {
flyToTerritory(t.shapeName)
}
}
// PG select → open the shared PeopleGroupDetail panel (S-D3). Reuses slice-13's
// selectedPG mechanism: find the live pin feature for this slug and hand it to
// uiStore.selectPeopleGroup (the SAME call attachPinHandlers makes on a pin click).
function openPgDetail(slug) {
if (!slug || !uiStore || typeof uiStore.selectPeopleGroup !== 'function') return
const src = map && map.getSource && map.getSource('upg')
const feats = (src && src._data && src._data.features) || []
const f = feats.find((x) => x.properties && x.properties.slug === slug)
if (f) uiStore.selectPeopleGroup(f)
}
// Zone fly-to: fitBounds of every tapestry feature whose zoneKey === zoneId. This
// is the SAME bounds path the legend-isolate uses (boundsFor), but it ONLY moves
// the camera — it does NOT re-filter the tapestry (search != isolate), so the
// shift-select / isolate / pin handlers are untouched (§1d).
function flyToZone(zoneId) {
if (!zoneId || !map || !tapestry) return
const b = boundsFor((f) => f.properties && f.properties.zoneKey === zoneId)
if (b) map.fitBounds(b, { padding: 40, duration: 800, maxZoom: 6 })
}
// Territory fly-to: fitBounds of the admin (props.shapeName === name) OR country
// (props.iso3 === name) polygon. The territory target.shapeName holds an ADM1
// name OR an ISO3 — match either on the tapestry source so countries + admins
// both fly from one path. Camera-only (no source/filter edit).
function flyToTerritory(name) {
if (!name || !map || !tapestry) return
const b = boundsFor((f) => {
const p = f.properties || {}
return p.shapeName === name || p.iso3 === name
})
if (b) map.fitBounds(b, { padding: 40, duration: 800, maxZoom: 6 })
}
// 09b-search-reuse-geocoder §1(a): the keyboard nav (↑/↓/Enter/Esc) + clear are
// GONE — the SHARED GeocoderComponent / mapbox-gl-geocoder owns them now
// (built-in keyboard nav, clear-X, and mobile behavior). No hand-rolled handlers.
// Compute bounds of all features matching a predicate (no turf dep).
function boundsFor(pred) {
let minX = 180, minY = 90, maxX = -180, maxY = -90, hit = false
const visit = (coords) => {
if (typeof coords[0] === 'number') {
const [x, y] = coords
if (x < minX) minX = x; if (x > maxX) maxX = x
if (y < minY) minY = y; if (y > maxY) maxY = y
hit = true
return
}
for (const c of coords) visit(c)
}
for (const f of tapestry.features) {
if (pred(f)) visit(f.geometry.coordinates)
}
return hit ? [[minX, minY], [maxX, maxY]] : null
}
// ── §1e row-click ISOLATE (S04t-D5) ─────────────────────────────────────────
// The legend's `select` emits the clicked node (or null). A leaf has a zoneKey;
// a parent (asia / india / china) isolates the SET of its descendant leaves.
function descendantKeys(node) {
const keys = new Set()
const walk = (n) => {
if (n.children && n.children.length) n.children.forEach(walk)
else keys.add(n.zoneKey ?? n.id)
}
walk(node)
return keys
}
let _isolatedNode = null // last isolated node, for re-apply after setStyle
// R1.2 D-6: when batch mode is ON, a legend row TOGGLES its zone(s) into the SAME
// selection union as the map territories (one mode, one union — supersedes the
// separate slice-06 mechanism). A leaf carries a zoneKey; a parent (asia/india/
// china) toggles the SET of its descendant leaves. Each descendant rides as kind
// 'zone' keyed 'zone:<zoneKey>' so selectionFilterExpr highlights every tapestry
// feature with that zoneKey. Returns true if it handled the select (batch mode).
function toggleZoneSelection(node) {
const keys = [...descendantKeys(node)]
if (!keys.length) return
// node label for the clipboard name; per-zone count from the legend node when
// the node IS a single leaf, else split each descendant leaf's own count.
const leaves = []
const walk = (n) => {
if (n.children && n.children.length) n.children.forEach(walk)
else leaves.push(n)
}
walk(node)
// toggle semantics: if EVERY descendant zone is already selected, remove them
// all (toggle OFF); otherwise add the missing ones (toggle ON).
const sel = selectedTerritories.value
const have = new Set(sel.filter((t) => t.kind === 'zone').map((t) => t.key))
const allIn = leaves.every((l) => have.has('zone:' + (l.zoneKey ?? l.id)))
if (allIn) {
const drop = new Set(leaves.map((l) => 'zone:' + (l.zoneKey ?? l.id)))
selectedTerritories.value = sel.filter((t) => !drop.has(t.key))
} else {
const add = leaves
.filter((l) => !have.has('zone:' + (l.zoneKey ?? l.id)))
.map((l) => ({
key: 'zone:' + (l.zoneKey ?? l.id),
name: l.label || l.id,
count: Number(l.count) || 0,
kind: 'zone',
}))
selectedTerritories.value = [...sel, ...add]
}
refreshSelectionPaint()
copyFlash.value = ''
}
// ── R1.3 (j): mark EVERY legend row that is part of the batch union ──────────
// The shared SemanticTreeLegend is single-select (it paints one .selected row by
// its internal selectedId). In batch mode the operator picks 2+ rows, so the
// profile decorates the legend itself: a row is "batch-selected" when ALL of its
// descendant leaf zoneKeys are in the current union (the SAME allIn rule
// toggleZoneSelection uses to add/remove). We match a visible row to a legend
// node by the rendered label text (the row's .stl-name), walking legendNodes to
// resolve that node's descendant zoneKeys. Profile-side ONLY — we never edit the
// shared component; we just toggle a profile class on rows inside the shadow root.
function selectedZoneKeySet() {
const s = new Set()
for (const t of selectedTerritories.value) {
if (t.kind === 'zone') s.add(t.key.slice(5)) // strip 'zone:' → bare zoneKey
}
return s
}
// label → descendant zoneKey set, built from the live legend tree.
function legendLabelDescendants() {
const byLabel = new Map()
const walk = (n) => {
const keys = descendantKeys(n) // Set of leaf zoneKey ?? id
if (n.label) byLabel.set(n.label, keys)
if (n.children && n.children.length) n.children.forEach(walk)
}
for (const n of legendNodes.value) walk(n)
return byLabel
}
function markLegendBatchRows() {
// Resolve the legend host inside this profile's shadow root.
if (typeof document === 'undefined') return
const host = document.querySelector('my-upg-100-list-map')
const root = (host && host.shadowRoot) || document
const rows = root.querySelectorAll('.stl-row')
if (!rows.length) return
// R1.4 progressive disclosure (L4): the multi-row markers are batch chrome —
// batch OFF clears EVERY marker, no matter what selection state is kept (D-7).
// (Pre-R1.4 the markers stayed alive whenever any zone was selected, leaving
// stale emerald rows fighting the single-select isolate chrome — the glitch.)
if (!batchMode.value) {
rows.forEach((r) => r.classList.remove('upg-batch-row'))
return
}
const sel = selectedZoneKeySet()
const byLabel = legendLabelDescendants()
const dark = isDark.value
rows.forEach((r) => {
const nameEl = r.querySelector('.stl-name')
const label = nameEl ? nameEl.textContent.trim() : ''
const keys = label && byLabel.get(label)
// a row is batch-selected when it has descendant zoneKeys AND every one of
// them is in the union (matches toggleZoneSelection's allIn add/remove rule).
const on = !!(keys && keys.size && [...keys].every((k) => sel.has(k)))
r.classList.toggle('upg-batch-row', on)
r.classList.toggle('dark', on && dark)
})
}
// Re-mark whenever the union, batch mode, OR theme changes; also re-mark when the
// legend re-renders its rows (expand/collapse/sort/tab swap rebuild the row DOM,
// which would drop our profile class). A MutationObserver on the rows container
// re-applies the markers after every legend re-render. nextTick lets Vue flush the
// legend DOM before we query it.
let _legendRowObserver = null
function watchLegendRows() {
if (typeof document === 'undefined' || _legendRowObserver) return
const host = document.querySelector('my-upg-100-list-map')
const root = (host && host.shadowRoot) || document
const container = root.querySelector('.stl-rows')
if (!container) return // legend not mounted yet; retried from onMounted poll
_legendRowObserver = new MutationObserver(() => {
// re-apply on the next frame so the row list has settled.
requestAnimationFrame(() => markLegendBatchRows())
})
_legendRowObserver.observe(container, { childList: true, subtree: true })
}
// LegendMobile routes row taps through an INJECTED callback rather than an emit
// (it owns its SemanticTreeLegend internally, so there is no event to bind from
// here). Providing it wires the mobile sheet to the same handler the desktop
// legend's @select uses — without this, mobile rows render but tap dead.
provide('onSemanticTreeSelect', onLegendSelect)
// The SAME handler the desktop legend gets via @export. Provided rather than passed as a
// prop because <LegendMobile> renders several levels down inside the shell, which is also
// why onSemanticTreeSelect above is provided. Without this the mobile sheet rendered its
// SemanticTreeLegend with exportEnabled defaulting to FALSE, so a phone reader could browse
// the lists and had no way to print a packet or a poster — the half of this profile the
// the agent called "the only way that map would be usable".
provide('onLegendExport', onLegendExport)
// ── LEGEND EXPORT (top-right of the legend titlebar) ────────────────────────
// The shared legend emits 'export' with 'poster' | 'packet' for the currently
// SELECTED node — the agent's ask: "I select one and it gives me print options at
// the top right of the legend... that's the only way that map would be usable."
//
// Poster serves the SAME pre-rendered artefact the poster wall downloads — one
// file, so the legend, the wall and the preview can never show three different
// things.
//
// ★ PACKET IS GENERATED IN THE BROWSER, ON CLICK — never shipped as an asset.
// One packet is ~3.5 MB (photos dominate); 28 zones would add ~100 MB to a repo
// that ALREADY had a publish blocked at a 74 MB asset tree. Generating on click
// costs zero repository bytes, and the photos come from URLs the records already
// carry. This is the agent's instruction: "instead of fetching the poster from
// the public folder I want the client side code to generate the poster real time
// when you click on the button so it does not take a lot of mb storage."
//
// The pages themselves come from library/packet/packet-render.js — the SAME module
// the build-time generator uses, so the printed packet and the generated one cannot
// drift. The card asked for exactly that: reuse, do not grow a fourth print path.
// ★ PRINT STRAIGHT FROM THE MAP — the agent's "that's the only way that map would be usable".
// Select a UPG-100 list in the legend and its print options appear at the legend's top right.
//
// ⚠ THE POSTER PATH USED TO HAND-ROLL AN <a download> OF THE .webp, which was wrong twice:
// it SILENTLY DOWNLOADED a file (ruled out 2026-09-23 — "no silent file download as the
// default action"), and it had no fallback, so for the lists whose .webp is not shipped it
// saved a 404. The wall had a correct implementation the whole time; it was private to
// PosterWall.vue, so this surface could not reach it. It now lives in usePosterPrints
// (printPoster) and BOTH surfaces call the one copy — which is what the card asked for:
// "reuse the existing three print actions rather than growing a fourth implementation."
async function onLegendExport(kind) {
const zoneId = selectedKey.value;
if (!zoneId) return; // the button only shows with a selection
if (kind === 'packet') { await printPrayerPacket(zoneId); return; }
// The legend selects by KEY, while the print actions label by item — hand over the zone
// object so the print dialog's title says the list's name rather than its slug.
// Same resolution printPrayerPacket uses, so both print actions name the list identically.
const zone = allLists().find((z) => z.id === zoneId) || { id: zoneId };
await packetPrints.printPoster(zone);
}
// Build and print a zone's prayer packet without leaving the page.
// PAGE ORDER IS THE PRODUCT SPEC: map · people groups 20/page with photos ·
// 12 prayer points · prayer wheel.
// ── THE PRAYER PACKET: the SHARED action ────────────────────────────────────
// This was a second hand-maintained copy of the packet print — its own renderer
// import, its own strings fetch, its own language resolution — beside the card
// deck's, which was consolidated first. Two copies of one fact drift.
//
// It now delegates to library/composables/usePosterPrints.js. What stays HERE is
// what genuinely cannot be shared: resolving which people groups are in a list
// (the people-groups API, the selector evaluator, and the pg_slugs special case
// that 12 of the 27 lists need), and where this bundle's posters are served from.
const packetPrints = usePosterPrints({
records: (zone) => packetRecords(zone),
lang: () => packetLang(),
label: (zone) => zone?.label || zone?.id || '',
// Where THIS bundle serves its posters from. printPoster needs it to find
// posters/<id>.webp and, when that is not shipped, posters/<id>.html to rasterise.
// Resolved here rather than inside the shared action, which knows nothing about a
// bundle's asset layout — the same seam printPacket uses for its map svg.
assetsBase: () => resolveAssetsBase(profileConfig.value),
downloadPrefix: 'doxa-upg-100',
});
// ⚠ BRIDGE THE PROGRESS BACK TO THE SURFACE THIS MAP ACTUALLY SHOWS.
// The composable reports through its own exportState; this profile has no HUD for
// that and displays `status` instead. Without this watcher the packet would print
// SILENTLY — no "building…", and a failure would vanish with nothing said — which
// is the mechanism working while the feedback does not.
watch(packetPrints.exportState, (st) => {
if (!st) { if (/packet/i.test(status.value)) status.value = ''; return; }
if (st.phase === 'error') {
status.value = `Packet failed: ${st.message}`;
setTimeout(() => { if (status.value.startsWith('Packet failed')) status.value = ''; }, 4000);
return;
}
status.value = st.phase === 'done' ? '' : `${st.message}…`;
});
// BUNDLE-SPECIFIC: who is in this list. ⚠ pg_slugs BYPASSES the shared evaluator —
// it intentionally THROWS on that type, and 12 of the 27 lists use it. Same
// special-case the map and the poster wall apply, so all three agree about
// membership.
async function packetRecords(zone) {
if (!API_BASE) throw new Error('no API configured');
const fields = 'slug,name,country_code,population,religion,image_url';
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
let slugs;
if (zone.selector && zone.selector.type === 'pg_slugs') {
slugs = new Set(zone.selector.values || []);
} else {
const { evaluate } = await import('./data/selector-evaluator.js');
slugs = new Set(evaluate(zone.selector, records)); // evaluate returns a Set
}
return records.filter((r) => slugs.has(r.slug))
.sort((a, b) => String(a.name).localeCompare(String(b.name), packetLang()));
}
// BUNDLE-SPECIFIC: the map page's <svg>, lifted from this bundle's own poster.
// Absent is fine — the packet prints a cover without the map rather than failing.
async function packetMapSvg(zoneId, R) {
try {
const base = resolveAssetsBase(profileConfig.value);
const html = await (await fetch(`${base}/posters/${zoneId}.html`, { cache: 'force-cache' })).text();
const svg = html.match(/<svg[\s\S]*?<\/svg>/);
if (!svg) return '';
return `<style>${R.mapCss(html)}</style>`
+ `<div style="position:absolute;left:0;top:250px;width:${R.W}px;`
+ `transform:scale(0.62);transform-origin:top center">${svg[0]}</div>`;
} catch { return ''; }
}
async function printPrayerPacket(zoneId) {
const zone = allLists().find((z) => z.id === zoneId);
if (!zone) return;
// The map svg needs the renderer's own mapCss/W, so it is resolved here and
// handed in rather than fetched inside the shared action, which knows nothing
// about where this bundle serves its posters.
const [{ createPacketRenderer }, strings] = await Promise.all([
import('@map/packet/packet-render.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: packetLang() });
await packetPrints.printPacket(zone, {
mapSvg: await packetMapSvg(zoneId, R),
funnel: await packetFunnel(),
});
}
// Every list across all three cluster buckets, flattened.
//
// ★ THE ONE SEAM THE `list` PROP WILL FEED. the agent 2026-09-26 via the shepherd: UPG-100 is the mass
// prayer mobiliser — a GENERATOR that takes a list in and puts posters, packets and cards out, so a
// partner can generate their own. Every list-consuming path in this profile goes through THIS
// function (allLists -> selectedLists, printPrayerPacket, the legend, the funnel), so when the
// 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.
function allLists() {
return [...(clusters.lists || []), ...(clusters.asia_lists || []),
...(clusters.uncovered_lists || [])];
}
// ── THE PAGE-2 FUNNEL CONTEXT ───────────────────────────────────────────────
// What the packet's funnel page needs that the packet itself cannot know: the nested IMB/network
// steps, how many lists the catalogue holds, and how many groups are in no list at all.
//
// ★ IT READS, IT DOES NOT COUNT. The four steps come verbatim from
// data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates from the IMB CSV
// and the live network list and which ALREADY drives the Vision Card. Re-deriving them here would
// give the same fact two sources that drift apart.
//
// ⚠ K AND THE UNASSIGNED COUNT ARE MEASURED FROM THE CATALOGUE AT CALL TIME, NEVER TYPED. Measured
// 2026-09-26: K = 27 (13 + 12 + 2 buckets) with per-list counts from 8 to 118, against a brief that
// said "~30 lists of 50-100" — only about half sit in that band, so no copy here claims it. The
// 27 lists account for 2,086 of the 2,107, leaving 21 in no list; the footnote states that plainly
// rather than letting a partner discover it by adding up rows (shepherd, ENDTOKEN-DXS-UPG-KDECIDE).
// ⚠ IT IS LIST-AWARE, AND IT HAS TO BE — THIS WAS A LIVE WRONG NUMBER, not a future risk. This
// profile already honours the universal `list` prop: applyPeopleGroupList() filters the rows at all
// four fetch sites, including packetRecords (:1258), and the membership files are on disk. So under
// ?list=frontier-500k the packet's rows are the 71 DOXA groups that are Project 700 — while the
// funnel, which took its denominator from the last step of vision-funnel.json, printed
// "of the 2,107". Off by a factor of 30, with nothing on the page looking broken.
// Measured 2026-09-26 from data/people-groups/lists/*.json (counts.doxa, what a Tier A map shows):
// frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 · doxa (default) 2,107
//
// ★ THREE NUMBERS WERE WRONG, NOT ONE, because all three were derived from DOXA's total:
// listTotal — the share's denominator;
// unassigned — the footnote's "N are in no list", computed as listTotal minus the assigned sum;
// listCount — 27, which is a property of the UPG-100 ZONE CATALOGUE, not of the active list. The
// zones partition DOXA; they do not partition frontier.
// So under a non-default list this states only what it can prove: the real total from the membership
// file, and NO listCount and NO unassigned — funnelPage omits the share line and the footnote rather
// than print a plausible wrong one. Half a fraction is not a fact.
async function packetFunnel() {
try {
const raw = (await import('../../data/people-groups/vision-funnel.json')).default;
if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null;
const listId = resolvePeopleGroupList(profileConfig);
const doxaTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
// THE DEFAULT LIST is the one the zone catalogue actually describes, so it is the only one that
// can honestly claim "1 of K lists" and an unassigned count.
if (listId === LIST_DEFAULT) {
const lists = allLists();
const assigned = lists.reduce((sum, l) => sum + (Number(l.count) || 0), 0);
return {
...raw,
listCount: lists.length,
listTotal: doxaTotal,
unassigned: Math.max(0, doxaTotal - assigned),
};
}
// A NON-DEFAULT LIST: read its own DOXA-side total from the membership file the build writes.
// counts.doxa is precisely "how many of this list DOXA carries", i.e. what a Tier A map shows.
// If it cannot be read, the share line is omitted — the steps above it are still true.
let listTotal = 0;
try {
const base = resolveAssetsBase(profileConfig.value);
const res = await fetch(`${base}/people-groups/lists/${listId}.json`);
if (res.ok) {
const m = await res.json();
listTotal = Number(m?.counts?.doxa) || (Array.isArray(m?.slugs) ? m.slugs.length : 0);
}
} catch (_) { /* leave listTotal 0 -> no share line */ }
return { ...raw, listTotal, listId, listLabel: (listInfo(listId) || {}).label || listId };
} catch (_) {
// A missing funnel file omits page 2 rather than failing the packet — see buildPacket.
return null;
}
}
// ── BATCH SELECTION MAKES A PACKET OR A POSTER ──────────────────────────────
// the agent: the batch tool is "super buggy", and recolouring regions is not what is wanted —
// "the batch selection tool should let you create your own poster or prayer packet based on
// your own selection." So the selection is an INPUT to the two artefacts this app already
// builds, and neither of these grows a third pipeline: both end in printPrayerPacket /
// packetPrints.printPoster, the same calls the legend and the poster wall make.
const batchBusy = ref(false);
// The zones behind the current selection. selectedTerritories holds 'zone:<id>' keys, and the
// list objects carry the selectors the packet needs; resolve back to those rather than
// re-deriving membership from the map, which would be a second source of truth.
function selectedLists() {
const want = new Set(selectedTerritories.value
.filter((t) => t.kind === 'zone')
.map((t) => String(t.key).replace(/^zone:/, '')));
return allLists().filter((l) => want.has(l.id));
}
// A SYNTHETIC LIST standing for the whole selection, so every existing action works unchanged.
// ⚠ ITS SELECTOR IS A pg_slugs UNION, NOT A LIST OF SELECTORS. 12 of the 27 lists already use
// pg_slugs and the evaluator THROWS on that type, so the selection is resolved to concrete slugs
// HERE — once, against one fetch of the people-groups list — and handed on as the type every
// consumer already handles. Resolving per zone would refetch the whole catalogue per zone and
// still leave the caller to merge.
async function selectionAsList() {
const lists = selectedLists();
if (!lists.length) throw new Error('nothing selected');
if (!API_BASE) throw new Error('no API configured');
const fields = 'slug,name,country_code,population,religion,image_url';
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
const { evaluate } = await import('./data/selector-evaluator.js');
const slugs = new Set();
for (const l of lists) {
if (l.selector && l.selector.type === 'pg_slugs') {
for (const s of (l.selector.values || [])) slugs.add(s);
} else {
for (const s of evaluate(l.selector, records)) slugs.add(s);
}
}
// The label is what the reader sees on the cover and in the print dialog's title. Name the
// territories they actually chose rather than inventing a title for their selection.
const names = lists.map((l) => l.label || l.id);
const label = names.length <= 3
? names.join(' + ')
: `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
return { id: 'selection', label, count: slugs.size,
selector: { type: 'pg_slugs', values: [...slugs] } };
}
async function makeSelectionPacket() {
if (batchBusy.value) return;
batchBusy.value = true;
try {
const sel = await selectionAsList();
// The packet's map page is per-zone artwork; a multi-zone selection has no single poster to
// lift it from, so it prints a cover WITHOUT the map rather than one zone's map standing in
// for all of them. packetMapSvg already treats a miss as fine.
const [{ createPacketRenderer }, strings] = await Promise.all([
import('@map/packet/packet-render.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: packetLang() });
const lists = selectedLists();
const mapSvg = lists.length === 1 ? await packetMapSvg(lists[0].id, R) : '';
// ⚠ "1 OF 27 LISTS" IS FALSE FOR A CUSTOM SELECTION — it is not one of the catalogue's lists at
// all. The NARROWING is still true and still the point, and "N of the 2,107" stays exactly true
// because N is the selection's own row count. So a multi-list selection keeps the funnel steps
// and drops listCount, which funnelPage already treats as "omit the share line". Same judgement
// the map above makes: print nothing rather than something that reads true and is not.
const funnel = await packetFunnel();
await packetPrints.printPacket(sel, {
mapSvg,
funnel: funnel && lists.length === 1 ? funnel : (funnel ? { ...funnel, listCount: 0 } : null),
});
} catch (e) {
status.value = `Selection packet failed: ${String(e?.message || e).slice(0, 90)}`;
setTimeout(() => { if (status.value.startsWith('Selection packet failed')) status.value = ''; }, 4000);
} finally { batchBusy.value = false; }
}
async function makeSelectionPoster() {
if (batchBusy.value) return;
batchBusy.value = true;
try {
const lists = selectedLists();
if (!lists.length) throw new Error('nothing selected');
// ⚠ A POSTER IS PRE-RENDERED PER ZONE — there is no artwork for an arbitrary union, and
// generating one would be the new pipeline the brief rules out. With one zone selected this
// is exactly the wall's poster; with several, say so plainly instead of silently printing
// the first zone's poster as though it covered the selection.
if (lists.length > 1) {
status.value = `Poster prints one territory at a time — ${lists.length} selected. `
+ 'Pick one, or use the prayer packet for the whole selection.';
setTimeout(() => { if (status.value.startsWith('Poster prints one')) status.value = ''; }, 5000);
return;
}
await packetPrints.printPoster(lists[0]);
} catch (e) {
status.value = `Selection poster failed: ${String(e?.message || e).slice(0, 90)}`;
setTimeout(() => { if (status.value.startsWith('Selection poster failed')) status.value = ''; }, 4000);
} finally { batchBusy.value = false; }
}
// The packet follows the app's locale so a reader gets it in their own language;
// packet-strings.json falls back PER KEY, so a half-finished translation renders
// its finished keys rather than blanking.
function packetLang() {
// useI18n()'s locale can be a plain string or a ref depending on how the host
// installed it, so normalise rather than assume.
const l = packetLocale && (packetLocale.value ?? packetLocale);
return (typeof l === 'string' && l) || 'en';
}
// ★ IT REPORTS WHETHER IT ACTED. Returns true when the selection was applied, false when it bailed.
//
// WHY: a silent `return` on a precondition is INDISTINGUISHABLE AT THE RENDERED LAYER from the
// request never arriving (regions-plugin + upg100, 2026-09-26). The "Show on map" interop bug had
// three layers stacked under one symptom — an element event with no replay, an id resolved against
// unloaded legendNodes, and THIS function no-op'ing on a missing layer — and each fix looked
// sufficient because all three produced the same nothing. Two were fixed by retaining the request;
// this one is the third, and it will grow a fourth layer for whoever comes next unless the applier
// says when it did not apply.
//
// ⚠ THE EARLY RETURN STAYS, and it stays SILENT of side effects — it is a documented readiness gate
// (see the comment at :630, "THE REAL READINESS SIGNAL"), and other code relies on it bailing rather
// than throwing or queueing. Returning a boolean is the smallest change that makes the bail
// OBSERVABLE without changing when it happens: a caller that cares can retain and retry, and the two
// callers that do not (paintAll's re-apply, the un-isolate at :945) are unaffected by a value they
// ignore.
function onLegendSelect(node) {
if (!map || !map.getLayer('tapestry-fill')) return false
// R1.2 D-6: in batch mode the legend feeds the selection union, NOT the isolate.
// (node is non-null on a row tap; the legend emits null when a row is re-tapped
// to deselect — in batch mode that null is a no-op, the union persists.)
if (batchMode.value) {
if (node) toggleZoneSelection(node)
// TRUE: batch mode HANDLED the request — it fed the union, which is the correct behaviour here
// rather than a failure to isolate. A caller retrying because it read `false` would toggle the
// zone back OUT of the union, so this distinction is load-bearing, not cosmetic.
return true
}
if (!node) {
// restore the full 26-zone tapestry
_isolatedNode = null
selectedKey.value = null
map.setFilter('tapestry-fill', null)
map.setFilter('tapestry-line', null)
// restore FULL opacity to match the new visible-dots baseline (BUG-UPG100-DOTS 2026-09-19)
map.setPaintProperty('upg-pins', 'circle-opacity', 1)
return true // the un-isolate IS the action; it succeeded
}
_isolatedNode = node
const keys = [...descendantKeys(node)]
selectedKey.value = keys.length === 1 ? keys[0] : (node.id ?? null)
const inSet = ['in', ['get', 'zoneKey'], ['literal', keys]]
map.setFilter('tapestry-fill', inSet)
map.setFilter('tapestry-line', inSet)
// dim pins outside the isolated zone(s)
if (map.getLayer('upg-pins')) {
map.setPaintProperty('upg-pins', 'circle-opacity',
['case', ['in', ['get', 'zoneKey'], ['literal', keys]], 0.95, 0.05])
}
const b = boundsFor((f) => keys.includes(f.properties.zoneKey))
if (b) map.fitBounds(b, { padding: 40, duration: 700, maxZoom: 6 })
// EXPLICIT, not an implicit undefined: the isolate was applied. An implicit fall-through returns
// undefined, which is falsy, so a caller checking the result would read the SUCCESS path as a
// failure and retry — the opposite of the bug this return value exists to expose.
return true
}
// ── ONE paint entrypoint (P0 4188af3d / LESSON-03 L7+L10) ────────────────────
// BOTH the cold boot AND the post-setStyle theme re-add paint through THIS
// function. Two divergent paths was the L7 bug class: the toggle path worked,
// the boot path stranded. Every helper inside is idempotent (guards on
// getSource()/getLayer()), so over-calling is safe; under-calling is the bug.
function paintAll() {
if (!map || !tapestry) return // data pipeline not done yet
ensureLayers() // tapestry fill + line
if (typeof addPinLayers === 'function') addPinLayers()
// 09b shift-select: re-add the selection highlight layers + re-bind the
// shift-click handler + re-apply the current selection paint so a user's
// in-progress land division survives a dark/light toggle.
ensureSelectionLayers()
attachTapestrySelect()
refreshSelectionPaint()
// Re-apply the current isolation filter if a zone is isolated (setStyle drops
// setFilter state along with the layers it was applied to). No-op at boot.
if (selectedKey.value && map.getLayer('tapestry-fill')) {
onLegendSelect(_isolatedNode)
}
}
// ── light/dark toggle (ThemeToggleButton @toggle) ───────────────────────────
// map.setStyle() WIPES every custom source + layer, so after the basemap swaps
// we MUST re-paint everything inside the style.load callback — exactly what
// research-map does (research-map.vue:988-1008). setStyle ALWAYS re-fires
// 'style.load', so (unlike the boot path) once('style.load') is safe here.
function handleToggleTheme() {
if (!map) return
isDark.value = !isDark.value
// 09b-9 legend-light-mode: drive the shared legend's theme off the basemap. The
// legend reads instance.theme.value for its :data-theme, so keeping it in lock-step
// with isDark makes the legend chrome follow light↔dark with the map underneath.
pplrInstance.theme.value = isDark.value ? 'dark' : 'light'
// ★ RESOLVE THE STYLE FOR THE ENGINE IN USE, as the boot path already does below. These two
// were hardcoded mapbox:// URLs, which MapLibre cannot load at all — so the map booted fine
// under ?provider=maplibre and then went blank the first time a reader touched the theme
// toggle. The registry names the same INTENT for both engines: 'paper' is light (mapbox
// light-v11 / OpenFreeMap positron), 'ink' is dark (dark-v11 / OpenFreeMap dark).
//
// Behaviour-preserving on Mapbox, and that is checkable rather than asserted: resolveNamedStyle
// returns byte-identical URLs to the literals this replaces — 'paper' -> light-v11,
// 'ink' -> dark-v11. Only the MapLibre branch changes, from an unloadable URL to a real one.
//
// The `||` fallbacks keep the old literals as a last resort if a name ever stops resolving,
// matching the boot path's own guard rather than inventing a second policy.
map.setStyle(isDark.value
? (resolveNamedStyle('ink', upgProvider).style || 'mapbox://styles/mapbox/dark-v11')
: (resolveNamedStyle('paper', upgProvider).style || 'mapbox://styles/mapbox/light-v11'))
map.once('style.load', paintAll)
}
// ── slice 13 §1b: pin-click → detail panel + hover cursor ────────────────────
// Factored into its own fn (called from addPinLayers, which runs on the initial
// draw AND on every post-setStyle theme re-add) so the click + cursor handlers
// survive a dark/light toggle. Click → uiStore.selectPeopleGroup(feature): the
// store stamps selectedPeopleGroup (the template v-if) and flips legendMode →
// 'detail'. The shared PeopleGroupDetail injects the same store, reads the
// feature's .properties, fetches /detail, and closes itself via
// selectPeopleGroup(null). useMapEvents.js:205-247 is the canonical pattern.
let _pinHandlersAttached = false
function attachPinHandlers() {
if (!map || !map.getLayer('upg-pins')) return
// Mapbox keeps its own per-layer listener list; after setStyle the layer is a
// fresh object so re-binding is correct. Guard only the cursor leak below.
map.on('click', 'upg-pins', (e) => {
const f = e && e.features && e.features[0]
if (!f) return
if (uiStore && typeof uiStore.selectPeopleGroup === 'function') {
uiStore.selectPeopleGroup(f) // opens panel (selectedPeopleGroup + legendMode='detail')
}
})
map.on('mouseenter', 'upg-pins', () => { if (map) map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'upg-pins', () => { if (map) map.getCanvas().style.cursor = '' })
_pinHandlersAttached = true
}
// ── CROSS-PANE SELECTION: "Show on map" from the poster wall ────────────────
// The wall dispatches a window event; the app-shell scrolls to this pane and
// re-dispatches 'doxa:select-zone' ON THIS ELEMENT. We listen here rather than
// on window so the shell decides which pane is the target — several embeds on
// one page therefore never cross-talk.
// detail.listId = a zoneKey to select, or NULL to deselect.
let _selectZoneHandler = null;
// ★ A REQUEST THAT ARRIVES TOO EARLY IS NOW REMEMBERED, NOT DROPPED.
// rega measured the defect: the FIRST "Show on map" on a poster card did nothing, while a SECOND
// one after the map had loaded isolated the zone and fitted the camera. So the listener and the
// handler both work -- the first request simply died before this component was ready for it.
// There were two ways for it to die and the fix has to survive both, which is why it retains the
// request rather than trying to attach the listener sooner:
// 1. the listener is attached at the END of an async onMounted, after 5 awaits, and only
// `if (host)` -- an element dispatch before that has no replay and is gone;
// 2. even once attached, the zone id is resolved against legendNodes, which is populated by the
// data fetch. Before that, findLegendNode returns nothing and the old code did
// `if (node) onLegendSelect(node)` with no else -- discarded silently, with no record that
// anything had been asked for.
// Retaining covers both without depending on which one fired.
let _pendingZoneId; // undefined = nothing outstanding
let _selectZoneObserver = null; // watches the retained `select-zone` attribute
/** The ONE apply path. Returns true when the request was satisfied (or was a deselect). */
// ★ THE APPLY REPORTS ITS OWN OUTCOME. rega's timeline proved delivery works (attribute written
// t=4106, and main already reads it at registration t=4763) and a headless test proved the lookup
// works (asia-india-rajasthan -> "Northwest India", 37 legend keys). So the failure is DOWNSTREAM of
// onLegendSelect and invisible: nothing throws, and "selected but not visibly selected" is
// indistinguishable at the rendered layer from "never selected". Writing the outcome onto the HOST
// makes it readable by rega's existing 50 ms poll, with no CDP and no new instrument.
// ★ NAMESPACED, on upg100-plugin's point: the host element is SHARED SURFACE. A partner page
// embedding this custom element could reasonably own an attribute called `data-select-zone-state`,
// and a diagnostic has no business squatting a generic name on someone else's element. The
// data-doxa- prefix makes the owner obvious and the collision risk nil.
// Renamed AFTER rega's re-shoot rather than before, deliberately: their probe polls these names, and
// renaming mid-flight would have burned a measurement instead of costing a few minutes.
function _markZoneState(state, zoneId) {
try {
const h = mapEl.value?.getRootNode?.()?.host;
if (!h) return;
h.setAttribute('data-doxa-select-zone-state', state);
if (zoneId) h.setAttribute('data-doxa-select-zone-applied', String(zoneId));
} catch (_) { /* never let a diagnostic break the apply */ }
}
function applySelectZone(zoneId) {
if (!zoneId) { onLegendSelect(null); _pendingZoneId = undefined; _markZoneState('cleared', ''); return true; }
const node = findLegendNode(legendNodes.value, zoneId);
if (!node) { _pendingZoneId = zoneId; _markZoneState('unresolved', zoneId); return false; } // not ready -- REMEMBER it
const applied = onLegendSelect(node);
// selectedKey (:163, "currently isolated zoneKey (or null)") is this component's own record of the
// isolation, so 'applied-but-no-selection' means the path was REACHED and its EFFECT lost -- a
// different bug from delivery, and the one rega's timeline points at.
// ★ I first wrote selectedZoneKey here, which does not exist; the try/catch would have swallowed
// the ReferenceError and reported 'applied' every time. A diagnostic that cannot report failure is
// worse than none. main's check:no-free-identifiers (6b05f513) now catches this class.
// ★ IF THE EFFECT DID NOT TAKE, THE REQUEST IS STILL OUTSTANDING. rega's re-shoot on 27eed341
// read exactly this state twice, 13 ms apart, with the right id -- so the node WAS found and
// onLegendSelect WAS called, and the selection still did not hold. The cause is onLegendSelect's
// own first line: `if (!map || !map.getLayer('tapestry-fill')) return`. It bails silently when the
// map's tapestry layer is not up, which at t=4.9s it was not. So the readiness signal was never
// legendNodes or mapRef -- it is THE LAYER, and the old code cleared _pendingZoneId here as though
// the work were done. Keeping it means ensureLayers() can drain it the moment the layer exists.
// ★ ASK THE APPLIER, DO NOT INFER FROM A SIDE EFFECT -- and the inference was WRONG IN BATCH MODE.
// upg100-plugin caught this prospectively: onLegendSelect feeds the selection UNION in batch mode
// and returns without ever setting selectedKey, because isolating is not what it should do there.
// So `!selectedKey.value` read that correct behaviour as a failure, and retaining on it would have
// re-applied the zone and toggled it back OUT of the union. Un-isolating (node == null) is the same
// shape: the un-isolate IS the action, and selectedKey is null afterwards BY DESIGN.
// They have made onLegendSelect return a boolean for exactly this (fb076c8b on packet/vertical),
// where false means one thing only: the map was not ready.
// FORWARD-COMPATIBLE ON PURPOSE. That change is not on main yet, and a function with no explicit
// return yields undefined, which is FALSY -- so depending on it today would read every success as a
// failure and retry it, which is the very trap they warned about. So the boolean is authoritative
// WHEN IT IS A BOOLEAN, and until then the old inference stands with batch mode excluded.
const ok = (typeof applied === 'boolean')
? applied
: (batchMode.value ? true : !!selectedKey.value);
if (!ok) { _pendingZoneId = zoneId; _markZoneState('applied-but-no-selection', zoneId); return false; }
_markZoneState('applied', zoneId);
return true;
}
function _onSelectZone(ev) {
const zoneId = ev?.detail?.listId ?? null;
// REUSE THE CANONICAL SELECT PATH. onLegendSelect(node) is what a legend row
// tap and the search bar already call: it isolates the zone, DIMS everything
// outside it, and fits the camera. Brief: "when I click Show on map it should
// be basically the same as if I searched that result in the search bar."
//
// Writing a second selection path here would have diverged from the legend and
// the search bar the first time any of them changed — so this resolves the zone
// to the SAME legend node object and hands it to the SAME function.
// null => onLegendSelect(null), the canonical un-isolate that restores the full
// tapestry. That is what makes the wall's green toggle honest.
applySelectZone(zoneId);
}
// Depth-first lookup of a legend node by zoneKey/id. The tree is now grouped into
// regional sections, so the target is a CHILD of a section rather than top-level.
function findLegendNode(nodes, key) {
for (const n of (nodes || [])) {
if ((n.zoneKey || n.id) === key) return n;
const hit = n.children && findLegendNode(n.children, key);
if (hit) return hit;
}
return null;
}
// P3-1: build the curated legend tree + wire each leaf's selector. Pure and
// synchronous — `clusters` is a static JSON import, so this needs no map, no token
// and no network, which is exactly why it must run before the map guards. Also
// seeds `leafZoneList` + `matchColorExpr`, which the map paint path consumes later
// (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 })
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
// 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
byId['deaf'] = { type: 'affinity_bloc', rop1: 'A017' }
for (const z of leafZoneList) z.selector = byId[z.id]
matchColorExpr = buildMatchColorExpr(leafZoneList)
}
// P3-1: degraded-path enrichment. The legend's second column is `pop`, which only
// exists once the live records are folded in — a plain fetch with no map dependency.
// Called ONLY from the map-guard early returns, so the healthy path (which fetches
// the same records as part of its pipeline) never double-fetches.
// Fails soft on purpose: if the API is unreachable too, the counts-only legend built
// by bootstrapLegend() stays on screen rather than being torn down by a throw.
async function enrichLegendFromRecords() {
if (!API_BASE) return
try {
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,latitude,longitude'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
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 })
} catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ }
}
onMounted(async () => {
// ── P3-1: NON-MAP UI FIRST, BEFORE ANY MAP GUARD ─────────────────────────────
// The legend/list is CURATED data (clusters.json — a static import, line 67) and
// needs no Mapbox instance, no token and no network. But it used to be built ~130
// lines BELOW the `if (!gl) return` guard, so a missing/blocked mapbox-gl left
// `legendNodes` at [] and BOTH legends failed their own `v-if="legendNodes.length"`.
// With the toolbar/geocoder also (correctly) gated on `mapRef`, the whole surface
// collapsed to ~10 nodes: an empty .upg-map over .upg-root's dark background, which
// reads as "the app is broken" rather than "the map is unavailable" — and the user
// lost the entire curated list, which never needed a map at all.
//
// NOTE the card named `v-if="mapRef"` as the sole gate. Measured, that is only half:
// the legends are gated on `legendNodes.length`, NOT on mapRef, so unhiding the
// mapRef subtree alone would still have rendered an EMPTY legend. The blank screen
// needs BOTH the early return (data never built) and the mapRef gate (controls
// hidden) to explain it. Sibling poster-wall.vue survives the same early return
// precisely because its content does not depend on state onMounted populates.
bootstrapLegend()
// Load the engine the PROVIDER selects, not the one this file used to assume.
try { await ensureEngineScript(upgProvider) } catch (_) { /* fall through to the !gl notice below */ }
const gl = engineGlobal(upgProvider) || window.mapboxgl
// ★ SHAPE RTL BASEMAP LABELS — HERE, not in index.js, and not after the map exists.
// This profile builds its map RAW (`new gl.Map` below) instead of going through
// useMapInstance, so it inherits none of the entry's side effects. It appeared to work only
// because the leaf head PRELOADED the Mapbox peer, which made an entry-time registration land
// in time; studio is removing that preload, and with the engine injected lazily an index.js
// call runs before `gl` exists and does nothing at all.
//
// ⚠ IT IS NOT A UI-LANGUAGE FEATURE, which is why it matters more than it looks. A basemap
// draws each place in its OWN script, so over the Middle East, Iran and Israel MOST labels are
// Arabic, Persian or Hebrew for EVERY viewer in EVERY UI language. Unshaped, they render
// reversed — a WRONG NAME for exactly the groups this map is about — and it fails silently:
// no error, no warning, just mirrored text that reads as a font problem to anyone who does not
// know the script. Arab World and Persian-Median are both in scope here.
//
// Idempotent: Mapbox throws if the plugin is set twice in a session and the helper swallows it,
// so this is safe alongside any other caller on the same page.
if (gl) installRtlTextPlugin(gl)
const token = mapboxToken?.value || profileConfig?.value?.tk || ''
// Degraded exits: keep the terse telemetry in `status`, put the human-readable
// notice in the MAP CELL, and still enrich the legend with live population data
// (a plain fetch — it needs no map). The list stays fully usable without tiles.
if (!gl) {
status.value = '⚠ map engine not loaded'
mapError.value = 'The map engine did not load. The list and legend are unaffected.'
enrichLegendFromRecords()
return
}
// ★ A TOKEN IS A MAPBOX REQUIREMENT, NOT A MAP REQUIREMENT. This gate returned early whenever
// there was no token, which under maplibre would have refused to draw a map that needs no
// token at all — turning the keyless option into a blank cell. MapLibre skips it entirely.
if (!isMaplibre) {
if (!token) {
status.value = '⚠ no mapbox token'
mapError.value = 'No Mapbox token configured. The list and legend are unaffected.'
enrichLegendFromRecords()
return
}
gl.accessToken = token
}
// P0 fix (LESSON-03 L4): mapbox-gl.css must live INSIDE the shadow root —
// the host page's <link> cannot penetrate it. Without it the geocoder's
// .mapboxgl-ctrl-top-left container is position:static and the search pill
// renders in-flow BELOW the map instead of top-left above the legend. The
// custom MapToolbar buttons never needed mapbox ctrl CSS, so this profile
// skipped it until the shared GeocoderComponent (a real mapbox ctrl) arrived.
// Mirrors src/composables/useMapInstance.js ensureMapboxCSS (idempotent).
// ⚠ MAPBOX ONLY. This ran unconditionally, so a keyless maplibre embed still pulled a
// stylesheet from api.mapbox.com — token-free and harmless to render, but wrong-engine
// leakage on a page whose whole point is having no Mapbox dependency. It was the last
// mapbox-under-maplibre reference in this profile, carried as a named exemption in the
// maplibre-embed gate; guarding it lets that exemption be deleted and enforced instead.
// MapLibre ships its own ctrl CSS via ensureEngineScript, so nothing is lost.
try {
const shadow = !isMaplibre && mapEl.value && mapEl.value.getRootNode()
if (shadow instanceof ShadowRoot) {
// ★ ASK THE REGISTRY FOR THE URL; DO NOT WRITE IT OUT AGAIN. This line used to hardcode
// v3.3.0 while every production loader (generate-tree's baked <script>, useMapInstance,
// generate-gallery) had moved to v3.24.0 — so the map ran NEW JS against OLD CSS.
// MEASURED on the live leaf: mapboxgl.version 3.24.0, the only mapbox stylesheet reaching
// the shadow root v3.3.0, .mapboxgl-canvas-container position:STATIC where Mapbox declares
// it absolute, .mapboxgl-canary display:block, and 0 markers. A canvas container with no
// positioning context leaves every absolutely-positioned child — the pins — with nothing to
// anchor to. the agent: "the polygons for the map and map colors and pins are not showing up".
// The comment at the top of this file already says the engine loader is imported rather than
// re-declared "because a second copy of one fact drifts"; the stylesheet URL was that second
// copy, and it drifted.
const MAPBOX_CSS = engineStylesheetUrl('mapbox')
if (MAPBOX_CSS && !shadow.querySelector(`link[href="${MAPBOX_CSS}"]`)) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = MAPBOX_CSS
shadow.prepend(link)
}
}
} catch (_) { /* CSS injection is best-effort; map still works without it */ }
map = new gl.Map({
container: mapEl.value,
// 'paper' is the registry's near-white minimal entry — mapbox light-v11 paired with
// OpenFreeMap positron — so the two engines show the same intent instead of this file
// hardcoding one engine's URL. Falls back to the literal if the name ever stops resolving.
style: resolveNamedStyle('paper', upgProvider).style || 'mapbox://styles/mapbox/light-v11',
center: [35, 20], zoom: 1.5, projection: 'mercator',
})
mapRef.value = map // expose the reactive instance to the controls
// Wire the edge scroll strips. This profile builds its map RAW (new gl.Map)
// instead of via useMapInstance, so nothing wires them for it — hence the
// explicit call. The wrapper passed is the strips' PARENT (.upg-root), which is
// what the composable queries; handing it the wrong ancestor is how simple-map's
// strips ended up rendering-but-inert (F30).
_detachEdges = useEdgeScrollAffordance(mapEl.value?.parentElement || null, {
enabled: EDGE_SCROLL_AFFORDANCE.value,
})
// 09b-6 §1g: expose the map instance for the Playwright-Firefox visual gate so
// it can assert tapestry fill features via map.queryRenderedFeatures (no visual
// diffing — a reviewer eyeballs the PNGs). Test-only handle; harmless in prod.
if (typeof window !== 'undefined') window.__UPG_MAP__ = map
// R1.2 PIVOT: NO keyboard listener (D-8 — modifier-key gestures removed for
// mobile parity). Expose a tiny test handle so the firefox gate can drive batch
// mode + the selection synthetically (the live map fires layer-scoped clicks the
// gate replays) and read back the running total + the exact clipboard string.
window.__UPG_SELECT__ = {
get list() { return selectedTerritories.value.map((t) => ({ ...t })) },
get total() { return selTotal.value },
get count() { return selCount.value },
get color() { return selectedColor.value }, // R1.1 gate probe
get batchMode() { return batchMode.value }, // R1.2 gate probe
setColor: (c) => setSelectionColor(c), // R1.1 gate hook
// R1.2 (f′): let the gate ENTER batch mode without a real button tap, then
// replay plain (no-modifier) clicks to prove the button-driven gesture works.
setBatchMode: (on) => setBatchMode(on),
clipboardText: () => formatClipboard(selectedTerritories.value, { color: selectedColor.value }),
clear: () => clearSelection(),
// resolve+toggle a feature's properties exactly as a plain batch-mode tap
// would, for deterministic headless proof (the gate also replays a real map
// click event — no shiftKey).
toggleByProps: (props) => {
const t = resolveTerritory(props, { nameMap: countryNameMap })
if (!t) return null
toggleTerritory(t)
return { ...t }
},
// ── R1.3 (f″) gate drivers ────────────────────────────────────────────────
// Toggle a LEGEND ROW by its visible label exactly as a batch-mode legend tap
// would — resolves the label to its legend node, then routes through
// toggleZoneSelection (the SAME union path a real row tap uses). Returns the
// node's descendant zoneKeys (count) so the gate can recompute the expected
// map-highlight feature count independently.
toggleLegendRow: (label) => {
const find = (nodes) => {
for (const n of nodes) {
if (n.label === label) return n
if (n.children && n.children.length) {
const hit = find(n.children); if (hit) return hit
}
}
return null
}
const node = find(legendNodes.value)
if (!node) return null
toggleZoneSelection(node)
return { label: node.label, zoneKeys: [...descendantKeys(node)] }
},
// The set of legend-row labels currently carrying the profile multi-select
// class (.upg-batch-row) — lets the gate assert BOTH rows read as selected at
// once (the headline R1.3 (j) acceptance), reading the live DOM not a number.
markedLegendRows: () => {
if (typeof document === 'undefined') return []
const h = document.querySelector('my-upg-100-list-map')
const r = (h && h.shadowRoot) || document
return [...r.querySelectorAll('.stl-row.upg-batch-row .stl-name')].map((e) => e.textContent.trim())
},
// Count tapestry features the map highlight (upg-sel-fill) currently matches —
// the union A∪B size the gate compares against the independent Node Σ of rows.
highlightFeatureCount: () => {
if (!map || !tapestry || !map.getLayer('upg-sel-fill')) return -1
const sel = new Set()
const adm1 = new Set(), adm0 = new Set()
for (const t of selectedTerritories.value) {
if (t.kind === 'zone') sel.add(t.key.slice(5))
else if (t.kind === 'adm1') adm1.add(t.name)
else if (t.kind === 'adm0') adm0.add(t.key.slice(5))
}
let n = 0
for (const f of tapestry.features) {
const p = f.properties || {}
if (sel.has(p.zoneKey) || adm1.has(p.shapeName) || adm0.has(p.iso3)) n++
}
return n
},
// expose the resolved zoneKeys for a legend label (independent Σ helper).
zoneKeysForRow: (label) => {
const find = (nodes) => {
for (const n of nodes) {
if (n.label === label) return n
if (n.children && n.children.length) { const h = find(n.children); if (h) return h }
}
return null
}
const node = find(legendNodes.value)
return node ? [...descendantKeys(node)] : null
},
}
// Legend tree + selectors are already built (bootstrapLegend() at the top of
// onMounted, before the map guards — P3-1). Nothing to do here.
try {
// ── FETCH the heavy geojson at runtime — do NOT import() it ──────────────
// These three files are 3.8 MB of boundary coordinates. They were written as
// dynamic import()s on the reasoning that this "keeps the payload out of the
// synchronous module-init path" — but the production build is a single-file
// IIFE with `inlineDynamicImports: true`, so EVERY dynamic import is inlined.
// Measured: 2.74 MB of the 4.18 MB bundle (66%) was raw coordinate floats.
// This is the same trap vite.config already documents for jspdf.
//
// A real fetch keeps them out of the bundle entirely: the shell, the view
// tabs and the poster wall become interactive immediately, and the boundaries
// stream in afterwards while the user can already navigate. They also become
// HTTP-cacheable, which an inlined payload never is.
status.value = 'loading boundaries…'
const boundaryUrl = (f) => `${resolveAssetsBase(profileConfig.value)}/boundaries/${f}`
const getJSON = (f) => fetch(boundaryUrl(f)).then((r) => {
if (!r.ok) throw new Error(`boundary fetch ${r.status} ${f}`)
return r.json()
})
const [adm0, adm1Ind, adm1Chn] = await Promise.all([
getJSON('world-adm0.geojson'),
getJSON('gb-IND-ADM1.geojson'),
getJSON('gb-CHN-ADM1.geojson'),
])
// ── live records WITH rop1 + population (full record; LESSON L3 / slice 04u) ──
// population resolves the deaf A017 row AND the legend's second column; one
// full fetch, no per-slice field subsets.
status.value = 'fetching 2106 UUPGs…'
if (!API_BASE) {
// Fail closed — no data host configured (window.MAP_APP_API_URL / VITE_API_BASE_URL).
throw new Error('No API base configured — set window.MAP_APP_API_URL or VITE_API_BASE_URL. Failing closed (no default host).')
}
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,latitude,longitude'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`list ${resp.status}`)
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
// 09b shift-select §1(b): build iso3→display-name from the LIVE records so an
// ADM0 country click resolves to the exact label the legend shows (the ADM0
// geojson features carry only iso3 — no name). Falls back to ISO3_NAME inside
// the resolver for any iso3 the records don't cover.
countryNameMap = buildCountryNameMap(records)
// 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 })
// ── resolve all 26 zones → ONE tapestry FeatureCollection ────────────────
const { tapestry: fc, perZone } = resolveAllZones(leafZoneList, { records, adm0, adm1Ind, adm1Chn })
tapestry = markRaw(fc)
// 09b-search-bar §1(a/c) + 09b-search-reuse-geocoder: build the unified
// search index + the geocoder feed. P0 fix (LESSON-03 L3): this MUST run
// AFTER `tapestry = markRaw(fc)` — the geocoder feed's centroidFor calls
// boundsFor(), which iterates tapestry.features; calling it before the
// tapestry exists threw "can't access property 'features', tapestry is null"
// into the pipeline catch, killing tapestry + pins + search (blank-ish UI).
buildSearch(records)
// ── pins layer (kept from 04b, now stamped with zoneKey for isolate dim) ──
// build slug→zoneKey using the same selectors (first match wins; regional before asia)
const pinFeatures = []
const slugToZone = {}
const { evaluate } = await import('./data/selector-evaluator.js')
// BUG-A FIX (§1a): the Asia rows carry a `pg_slugs` selector the evaluator
// intentionally THROWS on. The old loop called evaluate() on every zone, so the
// first Asia zone threw → the whole pin build aborted in the catch → zero pins.
// Mirror legend-tree.js:60-66: special-case pg_slugs (slug ∈ selector.values)
// BEFORE evaluate(), and wrap each zone so one bad selector can never abort all pins.
for (const z of leafZoneList) {
if (z.selector && z.selector.type === 'pg_slugs') {
for (const s of (z.selector.values || [])) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey
} else {
try {
for (const s of evaluate(z.selector, records)) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey
} catch (e) {
console.warn('zone selector skipped', z.id, e && e.message)
}
}
}
for (const r of records) {
const lat = Number(r.latitude), lng = Number(r.longitude)
if (!isFinite(lat) || !isFinite(lng)) continue
// slice 13 §1d / D-3: stamp the fields PeopleGroupDetail reads off the
// FEATURE properties so the panel header + Country/Population rows render
// INSTANTLY on click, before the /detail fetch resolves:
// • name → header + name-lead (.properties.name)
// • slug → detail fetch key + pray/adopt links (.properties.slug)
// • population → "Population" stat (.properties.population)
// • countryName → "Country" stat (countryName is the unified label
// the component prefers; normalize.js exposes the decoded
// label under country_code_label, value under country_code)
// • country → fallback country value (.properties.country)
// language/religion are NOT in the /list fetch — the component shows '—'
// for those until the /detail fetch (which DOES return them) resolves; the
// component's computeds read religion/language off feature props only, so
// those two stay em-dash here (acceptable per the QA em-dash fallback).
pinFeatures.push({
type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] },
properties: {
slug: r.slug,
zoneKey: slugToZone[r.slug] || '',
name: r.name || r.slug || '',
population: r.population ?? 0,
countryName: r.country_code_label || '',
country: r.country_code || '',
},
})
}
const pinFC = markRaw({ type: 'FeatureCollection', features: pinFeatures })
// Pin layer add, factored out so BOTH the initial draw() and the post-setStyle
// theme re-add (handleToggleTheme) can call it. Idempotent: guards getSource('upg').
addPinLayers = () => {
if (map.getSource('upg')) return
map.addSource('upg', { type: 'geojson', data: pinFC })
map.addLayer({
id: 'upg-pins', type: 'circle', source: 'upg', paint: {
// BUG-UPG100-DOTS-HARD-TO-SEE (2026-09-19): the previous floor (2.5px @ low zoom,
// 0.6px semi-transparent WHITE stroke) was still hard to see — a translucent white
// halo disappears on a LIGHT basemap. Fix, in three parts: a larger radius floor
// (2.5 -> 4px at zoom 1), a SOLID DARK stroke halo that reads on both light and dark
// basemaps, and full fill opacity.
// A mapbox circle layer has ONE stroke, so the halo IS the whole outline — there is
// no inner ring. (This comment used to claim "a thin white inner ring"; no such
// property was ever added, and a reader who believed it would go looking for a
// second layer that does not exist. Verified live 2026-09-21: the map reports
// stroke-color rgba(15,20,25,0.9), opacity 1, stroke-width 1.2->2 across zoom.)
// ★ ENLARGED AT LOW ZOOM (BUG-UPG100-DOTS-HARD-TO-SEE). Was 1,4 -> 4,6 -> 8,9.
// THE ASYMMETRY THAT MAKES THIS LIST DIFFERENT: all-people-groups draws ~12,379 dots, so
// small marks are correct there — density itself carries the signal, and a big dot would
// merge into a blob. This map draws ONE HUNDRED. At world zoom those 100 marks are alone
// on a whole planet, so the same 4px radius that reads as dense there reads as almost
// nothing here. Sized for the count, not copied from the sibling: 6px at z=1 (+50%),
// tapering back to the original 9px by z=8, where geography supplies the context and the
// dots no longer need to carry it alone.
// ★ REVERTED TOWARD THE SIBLINGS (the agent 2026-09-23: "the dots are way too big, I never
// asked for them to be that big, they're larger than the dots on most of the other maps").
// He is right and this was my overcorrection: an earlier pass raised z=1 from 4 to 6 to fix
// "hard to see", reasoning that 100 marks alone on a planet need more presence than
// all-people-groups' 12,379. The reasoning was sound but the amount was not — at 6 this map
// became the LARGEST of the set (all-people-groups is 2.2 at z=0, 3.4 at z=4), so it read as
// wrong rather than as emphasised. 5 keeps a deliberate step above the dense map without
// breaking the family; the halo below is what actually carries visibility at world zoom.
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 5, 4, 6.5, 8, 9],
'circle-color': matchColorExpr, 'circle-opacity': 1,
// Solid dark outer halo = the visibility win (frames every dot on any basemap).
// Widened in step with the radius so the halo stays a frame, not a smudge: a 1.2px ring
// around a 6px dot is proportionally thinner than around a 4px one, which would have
// spent the size increase and lost the contrast that made small dots findable at all.
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 1, 1.8, 8, 2],
'circle-stroke-color': 'rgba(15,20,25,0.9)',
},
})
// slice 13 §1b: wire the pin-click + hover-cursor handlers HERE so they
// re-attach on every layer (re)creation — the 'upg-pins' layer is wiped +
// re-added in handleToggleTheme after setStyle (04t-fix2), so attaching
// inside addPinLayers guarantees the panel still opens after a dark/light
// toggle. Idempotent guard mirrors getSource('upg') above.
attachPinHandlers()
}
// ── boot paint gate (P0 4188af3d / LESSON-03 L7+L10+L31) ─────────────────
// BUG-1 (04t) taught us addSource/addLayer require the STYLE to be loaded —
// but its gate `if (map.isStyleLoaded()) draw(); else map.once('style.load',
// draw)` had a STRANDING window: isStyleLoaded() requires style JSON + sprite
// + ALL tiles, while the 'style.load' EVENT fires at style-JSON-parse and
// never re-fires until setStyle. On a cold network the data pipeline lands
// inside that window → isStyleLoaded()=false AND style.load already fired →
// the initial paint never ran (no polygons/pins until a theme toggle).
//
// L31 REGRESSION (P0 4188af3d again): the L10 fix armed 'style.load' AND
// 'idle' on the (false) belief that "idle is GUARANTEED to fire later." It is
// NOT. This whole gate runs AFTER the async data pipeline (lazy geojson
// imports + a multi-second /people-groups fetch of 2106 records). On most
// reloads the basemap settles — both 'style.load' AND 'idle' fire — BEFORE
// the fetch resolves. By the time we attach the once() listeners here the map
// is already idle and quiescent: neither event re-fires (mapbox only emits
// 'idle' on the TRANSITION into idle), so the listeners hang forever and the
// first paint never runs. The theme toggle's setStyle is what finally re-fires
// 'style.load' → paintAll → polygons+pins appear. That is the exact symptom.
//
// FIX: keep the event listeners as the FAST path, but add a bounded
// isStyleLoaded() poll as the REAL safety net — it does not depend on any
// event re-firing, so it heals the already-settled-before-we-armed window.
// paintAll is idempotent; whichever path wins first cancels the rest.
let _paintArmed = true
const paintNow = () => {
if (!_paintArmed) return
_paintArmed = false
map.off('style.load', paintNow)
map.off('idle', paintNow)
paintAll()
}
if (map.isStyleLoaded()) {
paintNow()
} else {
// fast path: paint the instant either event fires…
map.once('style.load', paintNow)
map.once('idle', paintNow)
// …but never trust the events to fire — poll the actual style state so a
// map that already reached idle before we got here still paints.
const _poll = setInterval(() => {
// map may be torn down (unmount / HMR) mid-poll — bail cleanly.
if (!_paintArmed || !map) { clearInterval(_poll); return }
let ready = false
try { ready = map.isStyleLoaded() } catch (_) { clearInterval(_poll); return }
if (ready) { clearInterval(_poll); paintNow() }
}, 120)
// hard stop so a destroyed map (HMR / unmount) never leaves a live timer.
setTimeout(() => clearInterval(_poll), 15000)
}
// ★ DENOMINATOR IS DERIVED, NOT HARDCODED (2026-09-21). This read `${resolved}/26` and shipped
// "27/26 zones" — a count exceeding its own total, visible in two README screenshots. 26 was
// the leaf count BEFORE slice 09b-6 added the Frontier-Asia `uncovered_lists`; the literal was
// never updated, so the badge drifted the moment the data grew. perZone has exactly one entry
// per zone ATTEMPTED, so its size tracks leafZoneList automatically and can never disagree
// again. Measured from clusters.json: 13 regional + 12 asia + 2 uncovered + 1 deaf = 28.
// ★ THE DENOMINATOR COUNTS ZONES THAT SHOULD PAINT, NOT ZONES ATTEMPTED (2026-09-21).
// Deriving it from perZone.size fixed the impossible "27/26" but left a PERMANENT 27/28,
// which reads as "one zone is broken" — and I carded it as a bug on exactly that reading.
// It is not. resolveAllZones DELIBERATELY excludes the `deaf` zone from the region-fill
// tapestry and records 0 for it: its A017 affinity selector double-stamps land already owned
// by the 13 geographic zones (16 of oceania-indonesia's 18 Pacific ISO3 alone), which showed
// deaf twice and underlapped every other region. deaf remains complete in the LIST, the LEGEND
// and the PINS — only the polygon fill is withheld.
// So a by-design exclusion was being displayed as a shortfall. Counting only zones that are
// MEANT to have geometry makes the badge report 27/27 when everything is healthy, and drop
// below its total ONLY when a zone genuinely fails to resolve. Verified: 28 leaves - 1 deaf
// = 27 expected, 27 resolved.
const paintable = [...perZone.entries()].filter(([key]) => key !== 'deaf')
const resolved = paintable.filter(([, n]) => n >= 1).length
status.value = `${tapestry.features.length} polys · ${resolved}/${paintable.length} zones · ${pinFeatures.length} pins`
} catch (e) {
status.value = '⚠ ' + (e && e.message ? e.message : String(e))
}
// R1.3 (j): once the legend tree paints its rows, start the MutationObserver
// that re-applies the batch-row markers after every legend re-render, and do an
// initial mark. The legend mounts on legendNodes.length (post data pipeline), so
// poll briefly until .stl-rows exists, then hand off to the observer.
let _legendTries = 0
const startLegendWatch = () => {
const host = typeof document !== 'undefined' && document.querySelector('my-upg-100-list-map')
const root = (host && host.shadowRoot) || (typeof document !== 'undefined' ? document : null)
const container = root && root.querySelector('.stl-rows')
if (container) {
watchLegendRows()
markLegendBatchRows()
return
}
if (_legendTries++ < 40) setTimeout(startLegendWatch, 250) // up to ~10s
}
startLegendWatch()
// Attach the cross-pane selection listener on THIS custom element. The shell
// re-dispatches 'doxa:select-zone' here after scrolling to the map pane, so
// "Show on map" on a poster card selects and flies to that zone.
{
const host = mapEl.value?.getRootNode?.()?.host
if (host) {
_selectZoneHandler = _onSelectZone
host.addEventListener('doxa:select-zone', _selectZoneHandler)
// ★ THE RETAINED REQUEST, read from the ELEMENT rather than waited for.
// The shell also records the selection as a `select-zone` ATTRIBUTE on this element. An
// attribute survives custom-element upgrade and is readable whenever we get here, so a
// request made before this line ran is still available instead of having been dispatched
// into nothing. That is what makes the FIRST click work; the event above remains the live
// path for a map that is already up, so both triggers end at applySelectZone().
const requested = host.getAttribute('select-zone')
if (requested) applySelectZone(requested)
// And react to later writes, since the shell sets the attribute on every click.
_selectZoneObserver = new MutationObserver(() => {
applySelectZone(host.getAttribute('select-zone') || null)
})
_selectZoneObserver.observe(host, { attributes: true, attributeFilter: ['select-zone'] })
}
}
})
// When the legend data finally lands, satisfy anything that was asked for too early. This is the
// half that makes retention real -- without it the request is remembered and never applied.
//
// ★ ON nextTick, AND NOT AS A PRECAUTION. upg100-plugin flagged the race from experience with this
// file: watch([batchMode, isDark]) at :222 calls markLegendBatchRows() on nextTick, and this watcher
// would otherwise fire PRE-FLUSH -- so the selection would be applied against the legend rows that
// are about to be replaced by the re-render from these very legendNodes, and its row highlight
// could be stomped. That failure is invisible in the worst way: "the selection happened but you
// cannot see it" looks identical at the rendered layer to "the selection never happened", which is
// the bug this whole change exists to fix. Draining after the flush lets the rows exist and be
// marked first.
// ★ DRAIN ON EITHER READINESS SIGNAL, AND RE-READ THE ATTRIBUTE RATHER THAN THE BOOKKEEPING.
// rega measured legendNodes populated at t=1017 and the request at t=4106, so a legendNodes-change
// drain can never re-fire for it. Re-reading the host attribute makes the outcome independent of
// which hook fired when, which is the whole reason state was chosen over an event.
const _hostEl = () => { try { return mapEl.value?.getRootNode?.()?.host || null } catch (_) { return null } }
watch([legendNodes, mapRef], () => {
nextTick(() => {
const requested = _hostEl()?.getAttribute('select-zone') || _pendingZoneId || null
if (requested) applySelectZone(requested)
})
})
onBeforeUnmount(() => {
_selectZoneObserver?.disconnect();
_selectZoneObserver = null;
if (_selectZoneHandler) {
const host = mapEl.value?.getRootNode?.()?.host;
host?.removeEventListener('doxa:select-zone', _selectZoneHandler);
_selectZoneHandler = null;
}
// R1.2: no keydown listener to remove (modifier-key gestures dropped, D-8).
// R1.3: tear down the legend-row observer.
if (_legendRowObserver) { try { _legendRowObserver.disconnect() } catch (_) {} _legendRowObserver = null }
// Detach the edge strips BEFORE map.remove(): the composable holds a window
// 'scroll' listener, which would outlive this component and leak on every
// pane/profile swap.
if (_detachEdges) { try { _detachEdges() } catch (_) {} _detachEdges = null }
if (map) { map.remove(); map = null }
tapestry = null
})
</script>
<template>
<div class="upg-root">
<div ref="mapEl" class="upg-map"></div>
<!-- P3-1 graceful degrade: the tile/token failure is scoped to the MAP CELL.
Everything else on this surface (legend, list, batch HUD) renders from the
curated clusters.json and stays usable. Do NOT fake a map and do NOT hide
the error — surface it exactly where the map would have been, the same way
gospel-resources/resources-map.vue does with .gm-err. -->
<div v-if="mapError" class="upg-map-err">
<strong>Map unavailable</strong>{{ mapError }}
</div>
<!-- Edge scroll-affordance strips (the accessibility side bars). They live as
direct siblings of the map container inside .upg-root — that element is the
wrapper handed to useEdgeScrollAffordance in onMounted. (A strip placed in
some other ancestor is silently INERT: it renders but never lights. That is
the live bug in simple-map, F30.)
NOTE this profile builds its map RAW via `new gl.Map` rather than through
useMapInstance, so nothing wires the strips for it — the composable is
called explicitly. That is why adding the markup alone was not enough.
The gray gradient, hover-pin and touch-action:pan-y all come from the
shared composable — nothing profile-local.
Gated on EDGE_SCROLL_AFFORDANCE: a map with nothing beneath it to scroll to
must NOT advertise a scroll escape. -->
<template v-if="EDGE_SCROLL_AFFORDANCE">
<div class="map-scroll-edge map-scroll-edge--left" />
<div class="map-scroll-edge map-scroll-edge--right" />
</template>
<!-- 09b-search-reuse-geocoder §1(b): the SHARED <GeocoderComponent> — the
SAME search UI research-map mounts (top-left, ABOVE the legend, mobile-
friendly mapbox-gl-geocoder). CONSUMED, never edited (src/ md5 unchanged).
It adds itself to the map via addControl(map, 'top-left'); the legend
(.stl-panel, also top-left, lower z) sits BELOW it. It builds its own
localGeocoder from inject('dataStore') (we fed it in buildSearch via
cacheSourceData), emits `people-group-result` → onGeocoderResult, which
branches on the synthetic __upgKind to fly PG / zone / territory. Gated on
mapRef so it only mounts once the map instance exists; mobile-friendliness
+ keyboard nav + clear-X are all inherited from the component.
P0 fix (LESSON-03 L2): access-token must NOT use `.value` here — the
template auto-unwraps refs, so `mapboxToken?.value` was undefined and the
geocoder got '' → its mounted hook threw "Cannot create a client without
an access token", aborting Vue's post-flush mounted queue → legend +
controls mounted DEAD (the blank-UI failure). Script scope keeps
`.value`; template scope must not. -->
<GeocoderComponent
v-if="mapRef && hasGeocoder"
ref="geocoderRef"
:map-instance="mapRef"
:access-token="mapboxToken || profileConfig?.tk || ''"
:data-source-id="GEOCODER_SOURCE"
:is-dark="isDark"
:placeholder="geocoderPlaceholder"
:active-context="geocoderContext"
@people-group-result="onGeocoderResult"
@aggregate-result="onGeocoderAggregate"
@clear="onGeocoderClear"
/>
<!-- Shared control rail — same set + ordering as research-map. MapToolbar
self-positions top-right; the status badge is bottom-right (no collision).
Gated on mapRef so the buttons only mount once the map instance exists. -->
<MapToolbar v-if="mapRef" :has-geocoder="hasGeocoder">
<ZoomInButton :map="mapRef" :is-dark="isDark" />
<ZoomOutButton :map="mapRef" :is-dark="isDark" />
<LocationButton :map="mapRef" :is-dark="isDark" />
<FullscreenButton :map-container="mapEl" :is-dark="isDark" />
<ThemeToggleButton :is-dark="isDark" @toggle="handleToggleTheme" />
<!-- ★ NO MAP-LEVEL SHARE BUTTON HERE (the agent 2026-09-23, reported twice): "there's a share
button on the map, but there's ALSO a share button on the header of the website. That's
two separate share buttons. We don't need the one on the map."
The composite upg-100-mfe.vue:217 already mounts a ShareButton in the header, and that is
the one to keep — it shares the whole cap-4 element, whereas this one shared only the map
half, so the duplicate was also the less useful of the two.
★ REMOVED HERE, NOT IN THE SHARED COMPONENT: the share control is per-map policy, not a
global. Prayer Progress is the map intended for embedding on other people's sites and MUST
keep its own map-level button. A map that is only ever seen inside a composite (this one)
does not need one; a map that is embedded standalone does. -->
<!-- ShareButton intentionally omitted; the mfe header owns sharing for this bundle. -->
<!-- R1.3 (i) + R1.4 (L4): the Batch Select toggle lives IN THE MAP CONTROLS
RAIL and is THE SINGLE batch entry point (the HUD button is gone) — a
profile-LOCAL button slotted as a peer of the shared controls (we
consume the rail's slot host but do NOT edit any shared map-controls
src). It mirrors the .mcb sizing (round 40px, --map-btn-size) so it
lines up with Zoom/Location/Fullscreen/Theme, and carries the SAME
toggleBatchMode logic R1.2 built. Active = filled emerald (D-9).
aria-pressed + title="Batch Select" for a11y + the gate's G-rail query
(which targets this .upg-rail-batch, distinct from .mcb). -->
<!-- Uses the SHARED MapControlButton, exactly like the five buttons above it.
This was a hand-rolled <button class="upg-rail-batch mcb-shaped"> that
re-implemented .mcb locally — which is why it rendered LARGER than its
neighbours and sat out of line: its local rule added min-width/min-height:
40px on top of width/height, and that floor overrode the shared 36px
mobile rule, so every other control shrank and this one did not.
MapControlButton already provides isDark/title/active/disabled + a slot,
so nothing had to be added to the library. -->
<MapControlButton
:is-dark="isDark"
:active="batchMode"
:class="{ 'upg-batch-on': batchMode }"
:aria-pressed="batchMode ? 'true' : 'false'"
title="Batch Select"
@click="toggleBatchMode"
>
<!-- checklist / layered-squares glyph (D-9), matches the shared
BatchSelectButton icon language; emerald when batch mode is ON. -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
</MapControlButton>
</MapToolbar>
<!-- DESKTOP legend. Hidden below the mobile breakpoint, where <LegendMobile>
(the shared bottom-sheet) takes over — the same split research-map uses.
Previously this rendered at ALL widths and was the only legend, so on a
phone the profile had a desktop panel and no bottom sheet. -->
<div class="upg-legend-desktop-slot">
<SemanticTreeLegend
v-if="legendNodes.length"
:nodes="legendNodes"
:tabs="LEGEND_TABS"
:default-tab="LEGEND_DEFAULT_TAB"
:columns="['count', 'pop']"
:title="LEGEND_TITLE"
:export-enabled="true"
@select="onLegendSelect"
@export="onLegendExport"
/>
</div>
<!-- MOBILE legend — the SHARED bottom-sheet, fed this profile's own tree via
the `nodes` override (library/components/legends/LegendMobile.vue). Without
that override it would derive the research map's language-family taxonomy
from injected people-group data, which this curated list does not have.
legendType is a non-'default' sentinel so it takes the SemanticTreeLegend
branch rather than the flat LegendRows fallback. -->
<div class="upg-legend-mobile-slot">
<LegendMobile
v-if="legendNodes.length"
legend-type="upg-100"
:nodes="legendNodes"
:tabs="LEGEND_TABS"
:default-tab="LEGEND_DEFAULT_TAB"
:columns="['count', 'pop']"
column-label="UPG 100"
:title="LEGEND_TITLE"
:is-dark="isDark"
popup-action="pray"
/>
</div>
<!-- slice 13 §1c: pin-click → people-group detail side panel. Gated on
uiStore.selectedPeopleGroup (set by attachPinHandlers → selectPeopleGroup).
The shared PeopleGroupDetail injects the SAME uiStore and clears the
selection via its own close button (handleClose → selectPeopleGroup(null)),
which collapses this v-if. No @close emit exists on the component, so
store-driven visibility IS the close mechanism. .upg-detail-panel +
.pg-detail-root are the selectors the firefox visual gate queries. -->
<!-- DESKTOP ONLY. On mobile the shared <LegendMobile> bottom-sheet already
swaps itself to PeopleGroupDetail on the SAME uiStore.selectedPeopleGroup,
so rendering this panel too showed the people-group card TWICE for one pin
click. Research-map avoids the clash by keying its two surfaces off
DIFFERENT stores (legend = uiStore.selectedPeopleGroup, its own panel =
mapStore.selectedFeature); this profile routes both through uiStore, so the
breakpoint is what keeps them mutually exclusive — same 767px split as
.upg-legend-*-slot. -->
<div class="upg-detail-desktop-slot">
<div
v-if="uiStore && uiStore.selectedPeopleGroup"
class="upg-detail-panel pg-detail-root"
:class="{ dark: isDark }"
>
<PeopleGroupDetail
:peopleGroup="uiStore.selectedPeopleGroup"
:dark="isDark"
action="pray"
/>
</div>
</div>
<!-- R1.4 (L4 — Brief: single button + progressive disclosure): the land-
division HUD renders ONLY while batch mode is ON. The map-rail
.upg-rail-batch (above, in the MapToolbar) is the SINGLE batch entry
point — the old always-visible HUD .upg-batch-btn is gone. Inside batch
mode: hint, then running total + names + swatches + Copy/Clear once ≥1
territory/zone is selected. .upg-hud / .upg-hud-total / .upg-hud-btn are
the selectors the firefox gate reads. Band turns green in the 50–100
UUPG curation band. -->
<div v-if="batchMode" class="upg-hud">
<div class="upg-hud-hint">
Tap territories on the map or legend rows to add · tap again to remove
</div>
<div v-if="selCount" class="upg-hud-head">
<span>{{ selCount }} territor{{ selCount === 1 ? 'y' : 'ies' }} ·</span>
<span class="upg-hud-total">{{ selTotal }} UUPG</span>
<span class="upg-hud-band" :class="{ ok: selTotal >= 50 && selTotal <= 100 }">
{{ selTotal >= 50 && selTotal <= 100 ? '✓ 50–100 band' : 'band 50–100' }}
</span>
</div>
<div v-if="selCount" class="upg-hud-names">{{ selectedTerritories.map(t => t.name).join(', ') }}</div>
<!-- ★ THE SELECTION MAKES SOMETHING. the agent: the batch tool is "super buggy", and letting
you change the colour of regions is NOT what is wanted — "the batch selection tool
should let you create your own poster or prayer packet based on your own selection."
So the colour swatches and the hex input are GONE, and the actions that remain BUILD
the two artefacts this app already knows how to make, from exactly what was picked.
These reuse printPrayerPacket / printPoster rather than growing a third pipeline. -->
<div v-if="selCount" class="upg-hud-actions">
<button class="upg-hud-btn make" :disabled="batchBusy"
@click="makeSelectionPacket">{{ t('buttons.prayerList') }}</button>
<button class="upg-hud-btn make" :disabled="batchBusy"
@click="makeSelectionPoster">{{ t('buttons.posterMap') }}</button>
<button class="upg-hud-btn clear" @click="clearSelection">{{ t('buttons.clearSelection') }}</button>
</div>
<div v-if="selCount" class="upg-hud-flash">{{ copyFlash }}</div>
</div>
<div class="upg-status">{{ status }}</div>
</div>
</template>