2538 lines
150 KiB
Vue
2538 lines
150 KiB
Vue
<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>
|