UPG Neighbor: build the five neighbor maps from source, not from a frozen IIFE in an iframe

Send Map, the Bloom cockpit, Flower Mesh, Lang Cluster and Flowers ran in
the Experimental shelf as a 976 KB prebuilt legacy bundle inside a sandboxed
iframe. Their original source survived in the old app-profiles/doxa-upg-neighbor
bundle, and it only imports library pieces that still exist, so it moves here
as-is: the unified hub becomes upg-neighbor.vue and the lenses go in src/.

The hub now picks its opening lens at setup rather than on mount. Switching
on mount tore Send Map down mid-initialisation and threw on every other lens.
The five instance ids stay the same, so existing embed URLs still work, and
the old legacyElement key still maps to a lens.

The legacy sandbox (data/legacy/upg-neighbor and its staging rows) is gone
because nothing loads it any more.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 18:45:51 +00:00
parent 4f689a8fb1
commit 42c2279ee3
35 changed files with 42164 additions and 1517 deletions

View file

@ -1,75 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>legacy map host</title>
<style>html,body{margin:0;height:100%;background:#f3f3f1}.host{position:fixed;inset:0}</style>
</head>
<body>
<div class="host" id="host"></div>
<script>
// Sandboxed host for the LEGACY upg-neighbor IIFE (pre-bundler architecture). This page runs
// inside an IFRAME so the old engine and its dependencies cannot collide with the modern
// bundles -- the isolation pattern the roundup mandates ("add without breaking doxa-maps").
var q = new URLSearchParams(location.search);
var el = (q.get('el') || 'send-map').replace(/[^a-z0-9-]/g, '');
var tk = q.get('tk') || '';
// Token globals the legacy build may read.
window.MAPBOX_TOKEN = tk; window.MAP_TOKEN = tk; window.UPG_TOKEN = tk;
// The legacy code fetches relative "/api/people-groups/..." which only resolved on its old
// origin. Shim fetch so those calls reach the live API from ANY host (static tree, iframe).
var API = 'https://pray.doxa.life';
// The cockpit profile fails closed without a configured API base and names this global in
// its own error banner (measured: "No API base configured (apiBase / window.MAP_APP_API_URL
// / VITE_API_BASE_URL) -- failing closed").
window.MAP_APP_API_URL = API;
var origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
try {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.indexOf('/api/') === 0) return origFetch(API + url, init);
} catch (e) { /* fall through */ }
return origFetch(input, init);
};
// ── KEYLESS SHIM ──────────────────────────────────────────────────────────
// The legacy bundle expects a mapboxgl GLOBAL and requests mapbox://styles/mapbox/light-v11.
// MapLibre is API-compatible with the mapbox-gl v1 surface the bundle uses, and OpenFreeMap
// Positron is the keyless twin of light-v11 -- so the shim below makes every legacy map run
// WITHOUT a Mapbox token: load our self-hosted MapLibre, alias it as mapboxgl, and rewrite
// any mapbox:// style to the keyless URL inside a patched Map constructor.
var KEYLESS_STYLE = 'https://tiles.openfreemap.org/styles/positron';
var lib = document.createElement('link'); lib.rel='stylesheet'; lib.href='./maplibre-gl.css';
document.head.appendChild(lib);
var ml = document.createElement('script');
ml.src = './maplibre-gl.js';
ml.onload = function () {
var Real = window.maplibregl.Map;
function PatchedMap (opts) {
if (opts && typeof opts.style === 'string' && opts.style.indexOf('mapbox://') === 0) opts.style = KEYLESS_STYLE;
return new Real(opts);
}
PatchedMap.prototype = Real.prototype;
window.mapboxgl = Object.assign({}, window.maplibregl, { Map: PatchedMap, accessToken: '' });
loadLegacy();
};
ml.onerror = function () { loadLegacy(); };
document.head.appendChild(ml);
function loadLegacy () {
var s = document.createElement('script');
s.src = './upg-neighbor.iife.js';
s.onload = function () {
var node = document.createElement(el);
node.style.cssText = 'display:block;width:100%;height:100%';
// The legacy elements use the SAME profile-config attribute contract as the modern
// bundles (measured: without it the element renders its own usage banner).
node.setAttribute('profile-config', JSON.stringify({ profile: el, tk: tk }));
document.getElementById('host').appendChild(node);
};
s.onerror = function () { document.body.innerHTML = '<p style="font:14px system-ui;padding:20px">legacy bundle failed to load</p>'; };
document.head.appendChild(s);
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1400,11 +1400,6 @@ function main() {
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
'vendor/tabulator/tabulator.min.js',
'vendor/tabulator/tabulator.min.css',
// legacy sandbox — fetched by the upg-neighbor wrapper's iframe at runtime.
'legacy/upg-neighbor/index.html',
'legacy/upg-neighbor/upg-neighbor.iife.js',
'legacy/upg-neighbor/maplibre-gl.js',
'legacy/upg-neighbor/maplibre-gl.css',
// vendor/vue — the GALLERY SHELL's own Vue (generate-gallery.mjs loadVue()), not a bundle's.
//
// ★ ADDED 2026-09-22, and it had never been staged. generate-gallery.mjs:542 requests

View file

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upg-neighbor (dev)</title>
<link rel="icon" href="data:,">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#f3f3f1}
@ -12,7 +14,17 @@
</style>
</head>
<body>
<upg-neighbor profile-config='{"profile":"upg-neighbor","legacyElement":"send-map","assetsBase":"/public/assets"}'></upg-neighbor>
<!-- ?tk=<token> and ?view=<lens> (send-map · cockpit · flower-mesh · lang-cluster · flowers) -->
<script>
(function () {
var q = new URLSearchParams(location.search)
var el = document.createElement('upg-neighbor')
el.setAttribute('profile-config', JSON.stringify({
profile: 'upg-neighbor', view: q.get('view') || 'send-map', tk: q.get('tk') || window.MAPBOX_TOKEN || ''
}))
document.body.appendChild(el)
})()
</script>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -1,6 +1,35 @@
/**
* upg-neighbor / index.js — bundle entry (emits upg-neighbor.js), defines <upg-neighbor>.
*
* The standard ProfileLoader shape (see doxa-simple-map/index.js): the glob captures this
* folder's one profile, upg-neighbor.vue; its lenses live in src/. The five preset renders
* are rows of upg-neighbor.instances.json, each opening a different lens via `view`.
*
* Mapbox RTL text plugin: registered ONCE at module init — several SEND targets (Urdu/Arabic) are RTL.
*/
import { defineCustomElement } from 'vue'
import UpgNeighbor from './upg-neighbor.vue'
import { createPinia } from 'pinia'
import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
// One tag, three preset instances (see upg-neighbor.instances.json) — the standard cap-2 shape.
const El = defineCustomElement(UpgNeighbor)
if (!customElements.get('upg-neighbor')) customElements.define('upg-neighbor', El)
const profileModules = import.meta.glob('./*.vue')
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
try {
window.mapboxgl.setRTLTextPlugin(
'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
null, true
)
} catch (_) { /* already set this session */ }
}
const El = defineCustomElement(ProfileLoader, {
configureApp(app) {
app.use(createPinia())
const i18n = createAppI18n()
app.use(i18n)
app.provide('appI18n', i18n)
app.provide('profileModules', profileModules)
}
})
if (typeof customElements !== 'undefined' && !customElements.get('upg-neighbor')) customElements.define('upg-neighbor', El)

View file

@ -0,0 +1,195 @@
<script setup>
/**
* cockpit.vue — Application Profile: My UPG Neighbor · BLOOMFIELD COCKPIT (shell)
*
* @description Single-canvas crossfilter cockpit binding a red harvest MAP, a binned
* radial FLOWER, a sortable sender LIST and a 4-chart STRIP to ONE shared
* filterState store — reds-only by default, greens bloom on select, with a
* geo↔flower morph, language lens, color-by toggle and CSV export.
* @profile bloom-cockpit
* @bundle doxa-upg-neighbor
* @element bloom-cockpit
*
* THE FOUNDATION (this file): header (removable filter chips + search + honest counter +
* provenance pill) · upper row (MAP ~58% + FLOWER ~42% with a MAP⇄FLOWER segmented
* control) · lower row (CHART STRIP ~42% + SENDER LIST ~58%) · legend ribbon docked
* bottom. The four cells are imported from ./cockpit/ and INJECT the shared store
* (provided here). The Cells phase fills the cell bodies; this shell owns the grid,
* the store wiring, and the loading/error states.
*
* 0-DEPENDENCY / SHADOW-DOM SAFE: imports only Vue + @map/useShadowStyles + the local
* cockpit store + the four cells. All CSS via useShadowStyles (no per-cell <style>).
*
* profile-config: { "profile":"cockpit", "tk":"pk...", "apiBase":"https://pray.doxa.life",
* "target":"India", "instanceId":"..." }
*/
import { inject, ref, computed, onMounted, provide } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { createCockpitStore, COLORS } from './cockpit/useCockpit.js'
import MapCell from './cockpit/MapCell.vue'
import FlowerCell from './cockpit/FlowerCell.vue'
import ListCell from './cockpit/ListCell.vue'
import ChartStrip from './cockpit/ChartStrip.vue'
// ─── inject() from ProfileLoader ──────────────────────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const cfg = computed(() => profileConfig?.value || {})
// ─── the ONE shared store — created here, provided to every cell ──────────────
const store = createCockpitStore()
provide('cockpitStore', store)
const rootEl = ref(null)
onMounted(() => {
// seed an optional default target from profile-config (selected after load resolves)
store.loadData({ ...cfg.value, tk: mapboxToken?.value || cfg.value.tk })
// Test-only hook (inert in production — platform never sets debugExpose): expose the
// shared store on the host so a harness can drive interactions deterministically.
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try { (rootEl.value?.getRootNode?.()?.host || window).__cockpit = store } catch (_) {}
}
})
// ─── header helpers ───────────────────────────────────────────────────────────
// NOTE: `store` is a plain object, so its nested refs are NOT auto-unwrapped in the
// template — always read `store.X.value`. (No top-level ref aliases, to avoid the
// auto-unwrap inconsistency between `alias` and `store.alias`.)
function onSearch(e) { store.setSearch(e.target.value) }
function setMode(m) { store.setMode(m) }
// ─── Shadow-DOM CSS (single template; locked palette interpolated, no nested backticks) ─
useShadowStyles(`
.cpt-root { position:absolute; inset:0; display:grid; overflow:hidden;
grid-template-rows: auto minmax(0,1.45fr) minmax(0,1fr) auto;
grid-template-areas: "header" "upper" "lower" "legend";
background:#eef2f7; color:#1f2937; isolation:isolate; container-type:inline-size;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
/* ── HEADER ── */
.cpt-header { grid-area:header; display:flex; flex-wrap:wrap; gap:8px; align-items:center;
padding:8px 12px; background:${COLORS.NAVY}; color:#fff; border-bottom:1px solid #001b44; }
.cpt-brand { font:800 12px/1 ui-monospace,monospace; letter-spacing:.05em; text-transform:uppercase;
padding:6px 10px; background:rgba(255,255,255,.1); border-radius:7px; }
.cpt-chips { display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
.cpt-chip { display:inline-flex; align-items:center; gap:5px; font:600 11px/1 -apple-system,sans-serif;
padding:4px 8px; border-radius:999px; background:#fff; color:${COLORS.NAVY}; cursor:pointer; border:0; }
.cpt-chip.focus { background:${COLORS.GOLD}; color:#3a2600; }
.cpt-chip .x { font-weight:800; opacity:.6; }
.cpt-chip:hover .x { opacity:1; }
.cpt-search { flex:1 1 180px; min-width:120px; max-width:360px; margin-left:auto;
background:#0e1f44; border:1px solid #284a86; color:#dbe4f3; font:600 12px/1 -apple-system,sans-serif;
padding:7px 11px; border-radius:8px; }
.cpt-search::placeholder { color:#7e90b6; }
.cpt-counter { font:700 11px/1.3 ui-monospace,monospace; color:#cdd9f2; text-align:right; white-space:nowrap; }
.cpt-counter b { color:#fff; }
.cpt-prov { font:600 10px/1.3 ui-monospace,monospace; color:#9fb3d9; background:rgba(0,0,0,.25);
border:1px solid #284a86; border-radius:7px; padding:5px 9px; max-width:280px; }
.cpt-prov.err { color:#fecaca; border-color:#9f1239; }
/* ── UPPER ROW: map + flower ── */
.cpt-upper { grid-area:upper; display:grid; grid-template-columns: 58fr 42fr; gap:1px;
background:#cbd5e1; position:relative; min-height:0; }
.cpt-cell { position:relative; overflow:hidden; min-height:0; background:#fff; }
.cpt-seg { position:absolute; top:8px; left:50%; transform:translateX(-50%); z-index:40;
display:inline-flex; background:rgba(255,255,255,.95); border:1px solid #cbd5e1; border-radius:999px;
padding:3px; gap:2px; box-shadow:0 2px 10px rgba(0,0,0,.15); }
.cpt-seg button { border:0; background:transparent; color:#475569; cursor:pointer;
font:700 11px/1 ui-monospace,monospace; padding:6px 12px; border-radius:999px; }
.cpt-seg button.on { background:${COLORS.NAVY}; color:#fff; }
/* ── LOWER ROW: charts + list ── */
.cpt-lower { grid-area:lower; display:grid; grid-template-columns: 42fr 58fr; gap:1px;
background:#cbd5e1; min-height:0; }
/* ── LEGEND RIBBON ── */
.cpt-legend { grid-area:legend; display:flex; flex-wrap:wrap; gap:6px 16px; align-items:center;
padding:6px 12px; background:#0e1730; color:#cdd9f2; font:600 10px/1.4 ui-monospace,monospace;
border-top:1px solid #1c2742; }
.cpt-lg { display:inline-flex; align-items:center; gap:6px; }
.cpt-sw { width:12px; height:12px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(255,255,255,.3); }
.cpt-line { width:18px; height:0; border-top:2px solid; flex:0 0 auto; }
.cpt-ramp { display:inline-flex; gap:0; }
.cpt-ramp i { width:13px; height:11px; display:inline-block; }
/* ── loading / error overlay ── */
.cpt-overlay { position:absolute; inset:0; z-index:80; display:flex; align-items:center;
justify-content:center; background:rgba(238,242,247,.92); color:${COLORS.NAVY};
font:600 13px/1.5 ui-monospace,monospace; text-align:center; padding:24px; }
.cpt-overlay.err { color:${COLORS.RED_DK}; }
/* ── responsive: stack the two cells per row on narrow frames ── */
@container (max-width: 720px) {
.cpt-upper { grid-template-columns: 1fr; grid-template-rows: 1fr; }
.cpt-upper .cpt-cell.flower { display:none; }
.cpt-upper.flower-mode .cpt-cell.map { display:none; }
.cpt-upper.flower-mode .cpt-cell.flower { display:block; }
.cpt-lower { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
.cpt-prov { display:none; }
}
`, 'cockpit-shell')
</script>
<template>
<div ref="rootEl" class="cpt-root">
<!-- HEADER: brand · removable chips · search · honest counter · provenance pill -->
<div class="cpt-header">
<span class="cpt-brand">My UPG Neighbor · Overview</span>
<div class="cpt-chips">
<button v-for="(c, i) in store.chips.value" :key="i" class="cpt-chip" :class="{ focus: c.kind === 'focus' }"
@click="store.removeChip(c)" :title="'remove ' + c.label">
{{ c.label }} <span class="x">×</span>
</button>
</div>
<input class="cpt-search" type="search" :value="store.search.value" @input="onSearch"
placeholder="Search target / sender / language…" aria-label="Search" />
<span class="cpt-counter">
<b>{{ store.counts.value.senders.toLocaleString() }}</b> senders →
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> clusters · {{ store.counts.value.hidden }} hidden
</span>
<span class="cpt-prov" :class="{ err: store.provenance.value.apiError }"
:title="store.provenance.value.reachedFilter">
<template v-if="store.provenance.value.apiError">{{ store.provenance.value.apiError }}</template>
<template v-else>
LIVE {{ store.provenance.value.liveUnreached.toLocaleString() }} unreached · baked senders {{ store.provenance.value.bakedGenerated }}
</template>
</span>
</div>
<!-- UPPER ROW: MAP (hero) + FLOWER, with the MAP⇄FLOWER segmented control -->
<div class="cpt-upper" :class="{ 'flower-mode': store.mode.value === 'flower' }">
<div class="cpt-cell map"><MapCell /></div>
<div class="cpt-cell flower"><FlowerCell /></div>
<div class="cpt-seg" role="tablist" aria-label="Map or flower view">
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">MAP</button>
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">FLOWER</button>
</div>
</div>
<!-- LOWER ROW: CHART STRIP + SENDER LIST -->
<div class="cpt-lower">
<div class="cpt-cell"><ChartStrip /></div>
<div class="cpt-cell"><ListCell /></div>
</div>
<!-- LEGEND RIBBON (one shared key for all four cells) -->
<div class="cpt-legend">
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.RED }"></span> unreached target</span>
<span class="cpt-lg">reached %
<span class="cpt-ramp">
<i v-for="(g, i) in COLORS.GREEN_BINS" :key="i" :style="{ background: g }"></i>
</span>
</span>
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> SEND (solid=same-lang · dashed=cluster · dotted=in-country)</span>
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> selection · pop = log radius · Luke 10:2</span>
</div>
<!-- loading / error overlay -->
<div v-if="!store.ready.value" class="cpt-overlay">Loading the harvest field…</div>
<div v-else-if="store.loadError.value && !store.allClusters.value.length" class="cpt-overlay err">
{{ store.loadError.value }}
</div>
</div>
</template>

View file

@ -0,0 +1,356 @@
<script setup>
/**
* ChartStrip.vue — cockpit CHART STRIP cell (the crossfilter brushes + the global controls).
*
* Four ZERO-DEP hand-rolled SVG charts read from store.chartData and BRUSH the shared store
* on click, so the map pales, the flower sheds petals, the list contracts and the headline
* recomputes — all four cells breathe together (cockpit / crossfilter):
* 1) sender-language bar → toggleFacet('languages', …)
* 2) sender-country bar → toggleFacet('countries', …)
* 3) reached% quantile hist (5 GREEN bins) → setReachedMin(lower-edge)
* 4) bridge / religion donut → toggleFacet('bridges'|'religions', …) (follows colorBy)
*
* Plus the three global control seams the strip owns:
* • SEARCH with target / language / sender autocomplete → setSearch + selectTarget
* • LANGUAGE LENS picker → setLanguageLens (surfaces the bridge-language asset)
* • COLOR-BY toggle → setColorBy ('pct' depth ⇄ 'religion' family)
*
* 0-DEPENDENCY (vue + @map only). Shadow-DOM safe: ALL CSS via useShadowStyles (a child's own
* <style> is dropped by defineCustomElement). Locked color contract via COLORS / chartData.
*/
import { ref, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useCockpitStore, COLORS, GREEN_BIN_LABELS } from './useCockpit.js'
const store = useCockpitStore()
// ── local UI state (NOT filter state — that all lives in the store) ──────────────
const showSuggest = ref(false)
// ── crossfilter constants ────────────────────────────────────────────────────────
// lower %Christian edge of each quantile bin (matches quantileBin in useCockpit.js)
const BIN_MIN = [0, 10, 25, 50, 75]
const trunc = (s, n) => { const v = String(s ?? ''); return v.length > n ? v.slice(0, n - 1) + '…' : v }
// ── derived chart inputs (all from the shared, crossfilter-live store) ────────────
const cd = computed(() => store.chartData.value)
const donutKind = computed(() => (store.colorBy.value === 'religion' ? 'religions' : 'bridges'))
// distinct sender languages by count — feeds the lens picker + the search autocomplete
const langCounts = computed(() => {
const m = new Map()
for (const s of store.allSenders.value) { const k = s.lang || '—'; m.set(k, (m.get(k) || 0) + 1) }
return [...m.entries()].map(([lang, count]) => ({ lang, count })).sort((a, b) => b.count - a.count)
})
// SEARCH autocomplete: targets → languages → senders (capped) ----------------------
const suggestions = computed(() => {
const q = store.search.value.trim().toLowerCase()
if (q.length < 1) return []
const out = []
for (const c of store.allClusters.value) {
if (c.country.toLowerCase().includes(q)) {
out.push({ type: 'target', label: c.country + ' · ' + c.count + ' unreached', value: c.id, targetId: c.id })
if (out.length >= 6) break
}
}
for (const l of langCounts.value) {
if (l.lang.toLowerCase().includes(q)) { out.push({ type: 'lang', label: l.lang + ' · ' + l.count + ' senders', value: l.lang }); if (out.length >= 9) break }
}
for (const s of store.allSenders.value) {
if (s.name.toLowerCase().includes(q)) { out.push({ type: 'sender', label: s.name + ' → ' + s.country, value: s.name, targetId: s.targetId }); if (out.length >= 13) break }
}
return out.slice(0, 11)
})
// ── BAR layout (horizontal): language + country (shared geometry) ─────────────────
const BAR_VB_W = 300, ROW_H = 15, ROW_GAP = 5, BAR_X = 94, BAR_MAX_W = 150
function barRows(items, kind) {
const set = store.facets.value[kind]
const anyActive = set && set.size > 0
let max = 1
for (const d of items) if (d.value > max) max = d.value
return items.map((d, i) => {
const w = Math.max(2, (d.value / max) * BAR_MAX_W)
const active = set ? set.has(d.key) : false
return {
key: d.key, label: d.label, value: d.value,
y: i * (ROW_H + ROW_GAP), h: ROW_H, x: BAR_X, w,
ty: i * (ROW_H + ROW_GAP) + ROW_H - 4,
vx: BAR_X + w + 4,
active, dim: anyActive && !active
}
})
}
const barVbH = (items) => Math.max(ROW_H, items.length * (ROW_H + ROW_GAP))
const langRows = computed(() => barRows(cd.value.languageBar, 'languages'))
const countryRows = computed(() => barRows(cd.value.countryBar, 'countries'))
// ── reached% histogram (5 vertical GREEN quantile bars) ───────────────────────────
const HIST_VB_W = 230, HIST_VB_H = 120, HIST_TOP = 10, HIST_BOT = 96, HIST_L = 12
const histBars = computed(() => {
const bins = cd.value.reachedHist
let max = 1
for (const b of bins) if (b.value > max) max = b.value
const n = bins.length, slot = (HIST_VB_W - HIST_L - 8) / n, bw = slot * 0.7
const cur = store.facets.value.reachedMin
const plotH = HIST_BOT - HIST_TOP
return bins.map((b, i) => {
const h = (b.value / max) * plotH
const x = HIST_L + i * slot + (slot - bw) / 2
const min = BIN_MIN[i]
const active = cur != null && cur === min
return { key: i, label: GREEN_BIN_LABELS[i], value: b.value, color: b.color, x, w: bw, y: HIST_BOT - h, h, cx: x + bw / 2, min, active, dim: cur != null && cur !== min && min > 0 }
})
})
function onBin(b) {
if (b.min <= 0 || store.facets.value.reachedMin === b.min) store.setReachedMin(null)
else store.setReachedMin(b.min)
}
// ── bridge / religion donut (annular sectors, hand-built arc paths) ────────────────
const DON_CX = 80, DON_CY = 80, DON_RO = 70, DON_RI = 42
function annular(a0, a1) {
const px = (r, a) => DON_CX + r * Math.cos(a)
const py = (r, a) => DON_CY + r * Math.sin(a)
const large = (a1 - a0) > Math.PI ? 1 : 0
return 'M' + px(DON_RO, a0) + ' ' + py(DON_RO, a0) +
' A' + DON_RO + ' ' + DON_RO + ' 0 ' + large + ' 1 ' + px(DON_RO, a1) + ' ' + py(DON_RO, a1) +
' L' + px(DON_RI, a1) + ' ' + py(DON_RI, a1) +
' A' + DON_RI + ' ' + DON_RI + ' 0 ' + large + ' 0 ' + px(DON_RI, a0) + ' ' + py(DON_RI, a0) + ' Z'
}
const donutArcs = computed(() => {
const slices = cd.value.donut
const set = store.facets.value[donutKind.value]
const anyActive = set && set.size > 0
let total = 0
for (const d of slices) total += d.value
total = total || 1
let a = -Math.PI / 2
return slices.map(d => {
let span = (d.value / total) * Math.PI * 2
if (span >= Math.PI * 2) span = Math.PI * 2 - 0.0001
const a0 = a, a1 = a + span
a = a1
const active = set ? set.has(d.key) : false
return { key: d.key, label: d.label, value: d.value, color: d.color, path: annular(a0, a1), active, dim: anyActive && !active }
})
})
const donutTotal = computed(() => donutArcs.value.reduce((s, d) => s + d.value, 0))
// ── interaction handlers ──────────────────────────────────────────────────────────
function onSearchInput(e) { store.setSearch(e.target.value); showSuggest.value = true }
function pickSuggestion(s) {
if (s.type === 'target') { store.selectTarget(s.targetId); store.setSearch('') }
else if (s.type === 'sender') { if (s.targetId) store.selectTarget(s.targetId); store.setSearch(s.value) }
else { store.setSearch(s.value) }
showSuggest.value = false
}
function onLens(e) { store.setLanguageLens(e.target.value || null) }
// ── shadow-DOM-safe styles (a child's own <style> is dropped by defineCustomElement) ──
useShadowStyles(`
.cpt-cs{position:absolute;inset:0;display:flex;flex-direction:column;overflow:auto;
background:#f8fafc;color:${COLORS.NAVY};
font:600 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;}
/* control bar */
.cs-controls{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:center;
gap:8px;padding:8px 10px;background:rgba(248,250,252,.96);border-bottom:1px solid #e2e8f0;}
.cs-searchwrap{position:relative;flex:1 1 180px;min-width:140px;}
.cs-search{width:100%;box-sizing:border-box;padding:6px 9px;border:1px solid #cbd5e1;border-radius:8px;
background:#fff;color:${COLORS.NAVY};font:600 11px/1.2 ui-monospace,monospace;outline:none;}
.cs-search:focus{border-color:${COLORS.NAVY};box-shadow:0 0 0 2px rgba(0,46,110,.12);}
.cs-suggest{position:absolute;left:0;right:0;top:calc(100% + 3px);z-index:60;margin:0;padding:4px;
list-style:none;background:#fff;border:1px solid #cbd5e1;border-radius:8px;
box-shadow:0 8px 22px rgba(0,0,0,.16);max-height:230px;overflow:auto;}
.cs-suggest li{display:flex;align-items:center;gap:6px;padding:5px 7px;border-radius:6px;cursor:pointer;
font:600 11px/1.25 ui-monospace,monospace;color:${COLORS.NAVY};}
.cs-suggest li:hover{background:#eef2f7;}
.cs-tag{flex:0 0 auto;font:700 8px/1 ui-monospace,monospace;letter-spacing:.5px;color:#fff;
padding:2px 4px;border-radius:4px;background:#94a3b8;}
.cs-suggest li.t-target .cs-tag{background:${COLORS.RED};}
.cs-suggest li.t-lang .cs-tag{background:${COLORS.GOLD};}
.cs-suggest li.t-sender .cs-tag{background:${COLORS.NAVY};}
.cs-lens{display:inline-flex;align-items:center;gap:5px;font:700 10px/1 ui-monospace,monospace;color:#475569;}
.cs-lens select{padding:5px 6px;border:1px solid #cbd5e1;border-radius:7px;background:#fff;
color:${COLORS.NAVY};font:600 10px/1.1 ui-monospace,monospace;max-width:140px;}
.cs-colorby{display:inline-flex;align-items:center;gap:4px;font:700 10px/1 ui-monospace,monospace;color:#475569;}
.cs-colorby button{border:1px solid #cbd5e1;background:#fff;color:#475569;cursor:pointer;
font:700 10px/1 ui-monospace,monospace;padding:5px 8px;border-radius:7px;}
.cs-colorby button.on{background:${COLORS.NAVY};color:#fff;border-color:${COLORS.NAVY};}
/* charts grid */
.cs-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:8px 10px 12px;}
@container (max-width:520px){ .cs-grid{grid-template-columns:1fr;} }
.cs-card{display:flex;flex-direction:column;min-width:0;background:#fff;border:1px solid #e2e8f0;
border-radius:10px;padding:7px 8px;}
.cs-card header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;}
.cs-card h4{margin:0;font:700 10px/1.2 ui-monospace,monospace;letter-spacing:.3px;
text-transform:uppercase;color:#475569;}
.cs-clr{border:0;background:transparent;color:${COLORS.RED};cursor:pointer;
font:700 9px/1 ui-monospace,monospace;padding:2px 4px;}
.cs-clr:hover{text-decoration:underline;}
.cs-svg{width:100%;height:auto;display:block;}
.cs-empty{margin:8px 0;color:#94a3b8;font:600 10px/1 ui-monospace,monospace;text-align:center;}
/* horizontal bars */
.cs-barrow{cursor:pointer;}
.cs-bar{fill:${COLORS.NAVY};transition:opacity .15s;}
.cs-bar--country{fill:#1f5fa8;}
.cs-barrow:hover .cs-bar{fill:${COLORS.GOLD};}
.cs-barrow.active .cs-bar{fill:${COLORS.GOLD};}
.cs-barrow.dim{opacity:.4;}
.cs-blabel{fill:#334155;font:600 9px/1 ui-monospace,monospace;}
.cs-bval{fill:#64748b;font:600 8.5px/1 ui-monospace,monospace;dominant-baseline:alphabetic;}
.cs-barrow.active .cs-blabel,.cs-barrow.active .cs-bval{fill:${COLORS.NAVY};}
/* histogram */
.cs-axis{stroke:#cbd5e1;stroke-width:1;}
.cs-histbar{cursor:pointer;transition:opacity .15s;}
.cs-histbar rect{stroke:#fff;stroke-width:.5;}
.cs-histbar:hover rect{stroke:${COLORS.NAVY};stroke-width:1.5;}
.cs-histbar.active rect{stroke:${COLORS.GOLD};stroke-width:2;}
.cs-histbar.dim{opacity:.4;}
.cs-hval{fill:#334155;font:700 8px/1 ui-monospace,monospace;}
.cs-hlabel{fill:#64748b;font:600 6.5px/1 ui-monospace,monospace;}
/* donut */
.cs-donutwrap{display:flex;align-items:center;gap:8px;}
.cs-donut{flex:0 0 96px;width:96px;height:96px;}
.cs-arc{cursor:pointer;stroke:#fff;stroke-width:1;transition:opacity .15s;}
.cs-arc:hover{stroke:${COLORS.NAVY};stroke-width:1.5;}
.cs-arc.active{stroke:${COLORS.GOLD};stroke-width:2.5;}
.cs-arc.dim{opacity:.4;}
.cs-dctr{fill:${COLORS.NAVY};font:700 16px/1 ui-monospace,monospace;}
.cs-legend{flex:1 1 auto;margin:0;padding:0;list-style:none;max-height:104px;overflow:auto;min-width:0;}
.cs-legend li{display:flex;align-items:center;gap:5px;padding:2px 2px;cursor:pointer;border-radius:4px;
font:600 9.5px/1.3 ui-monospace,monospace;color:#334155;white-space:nowrap;overflow:hidden;}
.cs-legend li:hover{background:#eef2f7;}
.cs-legend li.active{color:${COLORS.NAVY};font-weight:800;}
.cs-legend li.dim{opacity:.45;}
.cs-legend li i{flex:0 0 auto;width:10px;height:10px;border-radius:3px;border:1px solid rgba(0,0,0,.12);}
.cs-legend li b{margin-left:auto;color:#64748b;}
`, 'chartstrip')
</script>
<template>
<div class="cpt-cs">
<!-- ── CONTROL BAR: search (autocomplete) · language lens · color-by ── -->
<div class="cs-controls">
<div class="cs-searchwrap">
<input class="cs-search" type="search" :value="store.search.value"
@input="onSearchInput" @focus="showSuggest = true"
@blur="showSuggest = false"
placeholder="Search target / language / sender…" aria-label="Search and brush" />
<ul v-if="showSuggest && suggestions.length" class="cs-suggest" role="listbox">
<li v-for="(s, i) in suggestions" :key="i" :class="'t-' + s.type" role="option"
@mousedown.prevent="pickSuggestion(s)">
<span class="cs-tag">{{ s.type === 'target' ? 'RED' : s.type === 'lang' ? 'LANG' : 'SEND' }}</span>
{{ s.label }}
</li>
</ul>
</div>
<label class="cs-lens">
<span>Lens</span>
<select :value="store.languageLens.value || ''" @change="onLens" aria-label="Language lens">
<option value="">— no lens —</option>
<option v-for="l in langCounts.slice(0, 40)" :key="l.lang" :value="l.lang">
{{ l.lang }} ({{ l.count }})
</option>
</select>
</label>
<div class="cs-colorby" role="group" aria-label="Color reached senders by">
<span>Color</span>
<button :class="{ on: store.colorBy.value === 'pct' }" @click="store.setColorBy('pct')">% reached</button>
<button :class="{ on: store.colorBy.value === 'religion' }" @click="store.setColorBy('religion')">religion</button>
</div>
</div>
<!-- ── THE 4 CROSSFILTER CHARTS ── -->
<div class="cs-grid">
<!-- 1) sender language bar -->
<section class="cs-card">
<header>
<h4>Sender language</h4>
<button v-if="store.facets.value.languages.size" class="cs-clr"
@click="store.facets.value.languages.forEach(v => store.toggleFacet('languages', v))">clear</button>
</header>
<svg v-if="langRows.length" class="cs-svg" :viewBox="'0 0 ' + BAR_VB_W + ' ' + barVbH(cd.languageBar)"
preserveAspectRatio="xMinYMin meet" role="img" aria-label="Senders by language">
<g v-for="r in langRows" :key="r.key" class="cs-barrow" :class="{ active: r.active, dim: r.dim }"
@click="store.toggleFacet('languages', r.key)">
<text class="cs-blabel" :x="BAR_X - 4" :y="r.ty" text-anchor="end">{{ trunc(r.label, 13) }}</text>
<rect class="cs-bar" :x="r.x" :y="r.y" :width="r.w" :height="r.h" />
<text class="cs-bval" :x="r.vx" :y="r.ty">{{ r.value }}</text>
</g>
</svg>
<p v-else class="cs-empty">no senders</p>
</section>
<!-- 2) sender country bar -->
<section class="cs-card">
<header>
<h4>Sender country</h4>
<button v-if="store.facets.value.countries.size" class="cs-clr"
@click="store.facets.value.countries.forEach(v => store.toggleFacet('countries', v))">clear</button>
</header>
<svg v-if="countryRows.length" class="cs-svg" :viewBox="'0 0 ' + BAR_VB_W + ' ' + barVbH(cd.countryBar)"
preserveAspectRatio="xMinYMin meet" role="img" aria-label="Senders by country">
<g v-for="r in countryRows" :key="r.key" class="cs-barrow cs-barrow--country" :class="{ active: r.active, dim: r.dim }"
@click="store.toggleFacet('countries', r.key)">
<text class="cs-blabel" :x="BAR_X - 4" :y="r.ty" text-anchor="end">{{ trunc(r.label, 13) }}</text>
<rect class="cs-bar cs-bar--country" :x="r.x" :y="r.y" :width="r.w" :height="r.h" />
<text class="cs-bval" :x="r.vx" :y="r.ty">{{ r.value }}</text>
</g>
</svg>
<p v-else class="cs-empty">no senders</p>
</section>
<!-- 3) reached% quantile histogram (5 GREEN bins → reached-floor brush) -->
<section class="cs-card">
<header>
<h4>% reached (quantile)</h4>
<button v-if="store.facets.value.reachedMin != null" class="cs-clr" @click="store.setReachedMin(null)">clear</button>
</header>
<svg class="cs-svg" :viewBox="'0 0 ' + HIST_VB_W + ' ' + HIST_VB_H"
preserveAspectRatio="xMidYMid meet" role="img" aria-label="Reached percent histogram">
<line class="cs-axis" :x1="HIST_L" :y1="HIST_BOT" :x2="HIST_VB_W - 4" :y2="HIST_BOT" />
<g v-for="b in histBars" :key="b.key" class="cs-histbar" :class="{ active: b.active, dim: b.dim }"
@click="onBin(b)">
<rect :x="b.x" :y="b.y" :width="b.w" :height="b.h" :fill="b.color" />
<text class="cs-hval" :x="b.cx" :y="b.y - 3" text-anchor="middle">{{ b.value }}</text>
<text class="cs-hlabel" :x="b.cx" :y="HIST_BOT + 12" text-anchor="middle">{{ b.label }}</text>
</g>
</svg>
</section>
<!-- 4) bridge / religion donut -->
<section class="cs-card">
<header>
<h4>{{ store.colorBy.value === 'religion' ? 'Religion family' : 'Bridge type' }}</h4>
<button v-if="store.facets.value[donutKind].size" class="cs-clr"
@click="store.facets.value[donutKind].forEach(v => store.toggleFacet(donutKind, v))">clear</button>
</header>
<div class="cs-donutwrap">
<svg v-if="donutTotal" class="cs-donut" viewBox="0 0 160 160"
preserveAspectRatio="xMidYMid meet" role="img" aria-label="Donut by category">
<path v-for="d in donutArcs" :key="d.key" class="cs-arc" :class="{ active: d.active, dim: d.dim }"
:d="d.path" :fill="d.color" @click="store.toggleFacet(donutKind, d.key)" />
<text class="cs-dctr" x="80" y="84" text-anchor="middle">{{ donutTotal }}</text>
</svg>
<ul class="cs-legend">
<li v-for="d in donutArcs" :key="d.key" :class="{ active: d.active, dim: d.dim }"
@click="store.toggleFacet(donutKind, d.key)">
<i :style="{ background: d.color }"></i>{{ trunc(d.label, 16) }} <b>{{ d.value }}</b>
</li>
</ul>
</div>
<p v-if="!donutTotal" class="cs-empty">no senders</p>
</section>
</div>
</div>
</template>

View file

@ -0,0 +1,408 @@
<script setup>
/**
* FlowerCell.vue — the BLOOM / MORPH radial flower of the BloomField Cockpit.
*
* Renders the FOCUSED red target at the center and its reached GREEN senders
* (store.visibleSenders, crossfilter-live) as a BINNED radial flower:
* • inner whorl = same-country senders (adjacency === 'same-country')
* • outer petals = cross-border senders (everything else)
* • navy spokes = the SEND path center → each sender (bridgeDash line style)
* • orange chords= same-language collaboration mesh, built by GROUPING (O(n)),
* NEVER the O(n²) all-pairs double loop of the old flower-mesh.
* Petals are capped (~24) with a single "+N more" petal that uncaps in place.
*
* THE SIGNATURE MORPH: every node carries BOTH a geographic position (gx,gy —
* its real lat/lng equirectangular-projected into the viewBox) and a flower
* position (fx,fy — its radial slot). A hand-rolled rAF tween of `morphT`
* (0 = geography, 1 = flower) lerps between them when store.mode toggles, so the
* selected cluster literally "flies off the map into the flower's heart" — and
* snaps instantly when prefers-reduced-motion is set. This REPLACES the dead
* upgn:drill-flower CustomEvent: it is driven purely by the shared store.
*
* 0-DEPENDENCY (vue + @map only). SHADOW-DOM SAFE — ALL css via useShadowStyles
* because a glob-loaded child's own <style> is dropped by defineCustomElement.
*/
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import {
useCockpitStore, COLORS, reachedColor, redColor, logRadius, bridgeDash, fmtPop
} from './useCockpit.js'
const store = useCockpitStore()
// ── geometry constants (square user-unit viewBox) ───────────────────────────
const VB = 1000
const CX = VB / 2
const CY = VB / 2
const R_IN = 212 // inner whorl — same-country
const R_OUT = 372 // outer petals — cross-border
const R_CTR = 48 // center node radius
const INNER_CAP = 8
const OUTER_CAP = 16
// ── local view state (NOT filter state — that lives only in the store) ──────
const morphT = ref(store.mode.value === 'flower' ? 1 : 0) // 0 geo → 1 flower
const showAll = ref(false) // "+N more" uncap
const hoveredId = ref(null)
const hoveredLang = ref('')
const tip = ref({ show: false, x: 0, y: 0, html: '' })
// ── helpers ─────────────────────────────────────────────────────────────────
function polar(cx, cy, r, angleDeg) {
const a = (angleDeg - 90) * Math.PI / 180
return [cx + r * Math.cos(a), cy + r * Math.sin(a)]
}
function langKey(s) { return String((s && (s.rol3 || s.lang)) || '').trim().toLowerCase() }
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
// ── categorical religion-family palette (stable, deterministic) ─────────────
const famColor = computed(() => {
const fams = [...new Set(store.visibleSenders.value.map(s => s.religionFamily || 'Unknown'))].sort()
const m = {}
fams.forEach((f, i) => { m[f] = COLORS.CAT[i % COLORS.CAT.length] })
return m
})
// ── the single layout pass: binned rings + dual (geo/flower) positions + mesh ─
const placed = computed(() => {
const ft = store.focusedTarget.value
if (!ft) return null
// rank by mobilization readiness (strongest × largest) so the cap keeps the best
const ranked = [...store.visibleSenders.value].sort((a, b) => (b.pct - a.pct) || (b.pop - a.pop))
const innerAll = ranked.filter(s => s.adjacency === 'same-country')
const outerAll = ranked.filter(s => s.adjacency !== 'same-country')
const showInner = showAll.value ? innerAll : innerAll.slice(0, INNER_CAP)
const showOuter = showAll.value ? outerAll : outerAll.slice(0, OUTER_CAP)
const hidden = (innerAll.length - showInner.length) + (outerAll.length - showOuter.length)
const hasMore = hidden > 0 && !showAll.value
// colorBy seam: pct-depth green ⇄ religion-family categorical
const fam = famColor.value
const colr = (s) => store.colorBy.value === 'religion'
? (fam[s.religionFamily || 'Unknown'] || COLORS.CAT[0])
: reachedColor(s.pct)
// equirectangular projection params over the shown senders + the target center
const lngs = [], lats = []
for (const s of showInner) { lngs.push(s.lng); lats.push(s.lat) }
for (const s of showOuter) { lngs.push(s.lng); lats.push(s.lat) }
let clng = ft.lng, clat = ft.lat
if (!isFinite(clng) || !isFinite(clat)) {
clng = lngs.length ? lngs.reduce((a, b) => a + b, 0) / lngs.length : 0
clat = lats.length ? lats.reduce((a, b) => a + b, 0) / lats.length : 0
}
lngs.push(clng); lats.push(clat)
const minX = Math.min(...lngs), maxX = Math.max(...lngs)
const minY = Math.min(...lats), maxY = Math.max(...lats)
const w = Math.max(maxX - minX, 0.01), h = Math.max(maxY - minY, 0.01)
const pad = 130
let scale = Math.min((VB - pad * 2) / w, (VB - pad * 2) / h)
if (!isFinite(scale) || scale <= 0) scale = 1
const cx0 = (minX + maxX) / 2, cy0 = (minY + maxY) / 2
const geo = (lng, lat) => [CX + (lng - cx0) * scale, CY - (lat - cy0) * scale]
// ring placement: inner spread 0°, outer spread half-step offset → interleaved flower
const nodes = []
const outerItems = showOuter.map(s => ({ kind: 'node', s }))
if (hasMore) outerItems.push({ kind: 'more' })
const Ni = showInner.length
showInner.forEach((s, k) => {
const ang = Ni ? (k + 0.5) / Ni * 360 : 0
const [fx, fy] = polar(CX, CY, R_IN, ang)
const [gx, gy] = geo(s.lng, s.lat)
nodes.push({ id: s.id, s, isMore: false, ring: 'inner', ang,
r: logRadius(s.pop, 7, 20), color: colr(s), lang: langKey(s), fx, fy, gx, gy })
})
const No = outerItems.length
const offset = No ? 180 / No : 0
outerItems.forEach((it, k) => {
const ang = No ? (k + 0.5) / No * 360 + offset : 0
const [fx, fy] = polar(CX, CY, R_OUT, ang)
if (it.kind === 'more') {
const [gx, gy] = geo(clng, clat)
nodes.push({ id: '__more__', isMore: true, ring: 'outer', ang, r: 18,
color: COLORS.NAVY, lang: '', fx, fy, gx, gy, hidden })
} else {
const s = it.s
const [gx, gy] = geo(s.lng, s.lat)
nodes.push({ id: s.id, s, isMore: false, ring: 'outer', ang,
r: logRadius(s.pop, 7, 20), color: colr(s), lang: langKey(s), fx, fy, gx, gy })
}
})
// center (the unreached red target) — dual position too, so it slides to heart
const [gcx, gcy] = geo(clng, clat)
const center = { fx: CX, fy: CY, gx: gcx, gy: gcy,
country: ft.country, count: ft.count, color: redColor(ft.count) }
// same-language mesh — GROUP then chain consecutive (O(n) total, never O(n²)),
// and only across CURRENTLY-DISPLAYED (i.e. currently-filtered) petals.
const groups = new Map()
for (const n of nodes) {
if (n.isMore || !n.lang) continue
let g = groups.get(n.lang)
if (!g) { g = []; groups.set(n.lang, g) }
g.push(n)
}
const mesh = []
for (const [lang, arr] of groups) {
if (arr.length < 2) continue
for (let i = 0; i < arr.length - 1; i++)
mesh.push({ id: 'mesh-' + arr[i].id + '-' + arr[i + 1].id, from: arr[i].id, to: arr[i + 1].id, lang })
}
return { nodes, center, mesh, hidden, shown: showInner.length + showOuter.length, total: ranked.length }
})
const nodeById = computed(() => {
const m = {}
if (placed.value) for (const n of placed.value.nodes) m[n.id] = n
return m
})
// lit set keyed by hovered language — O(1) per node, never a per-node scan
function isLit(n) { return !hoveredLang.value || (!n.isMore && n.lang === hoveredLang.value) }
function isMeshLit(link) { return !hoveredLang.value || link.lang === hoveredLang.value }
function isFaceted(n) {
return !n.isMore && store.facets.value.languages.has(n.s.lang)
}
// ── morph position accessors (lerp geo→flower by morphT) ─────────────────────
function mx(n) { return n.gx + (n.fx - n.gx) * morphT.value }
function my(n) { return n.gy + (n.fy - n.gy) * morphT.value }
function mcx() { const c = placed.value.center; return c.gx + (c.fx - c.gx) * morphT.value }
function mcy() { const c = placed.value.center; return c.gy + (c.fy - c.gy) * morphT.value }
function spokeD(n) {
return 'M ' + mcx().toFixed(1) + ' ' + mcy().toFixed(1) +
' L ' + mx(n).toFixed(1) + ' ' + my(n).toFixed(1)
}
function meshD(link) {
const a = nodeById.value[link.from], b = nodeById.value[link.to]
if (!a || !b) return ''
const ax = mx(a), ay = my(a), bx = mx(b), by = my(b)
const midx = (ax + bx) / 2, midy = (ay + by) / 2
// curve inward toward heart only as it blooms; straight in geography
const ctrlX = midx + (CX - midx) * 0.45 * morphT.value
const ctrlY = midy + (CY - midy) * 0.45 * morphT.value
return 'M ' + ax.toFixed(1) + ' ' + ay.toFixed(1) +
' Q ' + ctrlX.toFixed(1) + ' ' + ctrlY.toFixed(1) + ' ' + bx.toFixed(1) + ' ' + by.toFixed(1)
}
function dashStr(n) {
if (n.isMore) return '4 6'
const d = bridgeDash(n.s.bridge) // null=solid | [4,3]=dashed | [1,3]=dotted
return d ? d.map(v => v * 3).join(' ') : 'none'
}
function petalXY(n) { return [mx(n), my(n)] }
// ── interactions (drive the SHARED store; never own filter state) ───────────
function onEnter(n, ev) {
hoveredId.value = n.id
hoveredLang.value = n.isMore ? '' : n.lang
if (!n.isMore) { try { store.setHover && store.setHover(n.id) } catch (_) {} } // shared hover bus → list scroll
if (n.isMore) {
tip.value = { show: true, x: ev.clientX, y: ev.clientY,
html: '<strong>+' + n.hidden + ' more senders</strong>' +
'<div class="cf-tip-sub">click to bloom the full roster</div>' }
return
}
const s = n.s
tip.value = { show: true, x: ev.clientX, y: ev.clientY,
html: '<strong>' + esc(s.name) + '</strong>' +
'<div class="cf-tip-sub">' + esc(s.country) + ' · ' + esc(s.lang || '—') + '</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">reached</span> ' + Math.round(s.pct || 0) + '% Christian</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">people</span> ' + fmtPop(s.pop) + '</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">bridge</span> ' + esc(s.bridge || '—') + '</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">near</span> ' + esc(s.adjacency || '—') + '</div>' }
}
function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
function onLeave() { hoveredId.value = null; hoveredLang.value = ''; try { store.setHover && store.setHover(null) } catch (_) {} ; tip.value = { ...tip.value, show: false } }
function onClick(n) {
if (n.isMore) { showAll.value = true; return }
// crossfilter by this sender's language (same gesture as the chart-strip language bar)
store.toggleFacet('languages', n.s.lang)
}
function toggleBloom() { store.setMode(store.mode.value === 'flower' ? 'map' : 'flower') }
// ── the hand-rolled tween (rAF; prefers-reduced-motion aware) ───────────────
const reduced = typeof window !== 'undefined' && window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
let raf = 0
function animateTo(target) {
cancelAnimationFrame(raf)
if (reduced || typeof requestAnimationFrame === 'undefined') { morphT.value = target; return }
const start = morphT.value
const t0 = performance.now()
const dur = 720
const ease = (p) => p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2
const step = (now) => {
const p = Math.min(1, (now - t0) / dur)
morphT.value = start + (target - start) * ease(p)
if (p < 1) raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
}
watch(() => store.mode.value, (m) => animateTo(m === 'flower' ? 1 : 0))
watch(() => store.focusedTargetId.value, () => { showAll.value = false })
onBeforeUnmount(() => cancelAnimationFrame(raf))
// ── shadow-DOM CSS (single template literal, consts interpolated, no nested backticks) ─
useShadowStyles(
'.cpt-flower{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;isolation:isolate;' +
'background:radial-gradient(circle at 50% 44%, #16203a 0%, #0b1020 72%);color:#e5e7eb;' +
'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;}' +
'.cf-bar{position:absolute;top:10px;left:10px;right:10px;z-index:30;display:flex;gap:8px;align-items:center;' +
'flex-wrap:wrap;pointer-events:none;}' +
'.cf-bar > *{pointer-events:auto;}' +
'.cf-title{background:' + COLORS.NAVY + ';color:#fff;font:700 11px/1 ui-monospace,monospace;letter-spacing:.07em;' +
'text-transform:uppercase;padding:8px 12px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.4);}' +
'.cf-meta{font:600 11px/1.4 ui-monospace,monospace;color:#9fb3d9;background:rgba(8,12,24,.7);' +
'border:1px solid #1c2742;border-radius:8px;padding:6px 10px;}' +
'.cf-meta b{color:#fff;}' +
'.cf-bloom{margin-left:auto;background:#13224a;border:1px solid #3f7bd6;color:#fff;cursor:pointer;' +
'font:700 11px/1 ui-monospace,monospace;letter-spacing:.04em;padding:8px 13px;border-radius:8px;' +
'display:inline-flex;align-items:center;gap:7px;user-select:none;}' +
'.cf-bloom:hover{background:#1b2e5e;}' +
'.cf-bloom .cf-dot{width:8px;height:8px;border-radius:50%;background:' + COLORS.GOLD + ';}' +
'.cf-stage{flex:1 1 0;position:relative;}' +
'.cf-svg{position:absolute;inset:0;width:100%;height:100%;display:block;}' +
'.cf-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;' +
'color:#7e90b6;font:600 12px/1.6 ui-monospace,monospace;padding:24px;}' +
'.cf-petal{transition:opacity .15s;}' +
'.cf-spoke{fill:none;stroke:' + COLORS.NAVY + ';stroke-width:2;opacity:.45;transition:opacity .15s;}' +
'.cf-spoke.lit{opacity:.9;stroke-width:2.6;}' +
'.cf-spoke.dim{opacity:.06;}' +
'.cf-mesh{fill:none;stroke:' + COLORS.GOLD + ';stroke-width:1.6;opacity:.5;stroke-linecap:round;transition:opacity .15s;}' +
'.cf-mesh.lit{opacity:.95;stroke-width:2.6;}' +
'.cf-mesh.dim{opacity:.05;}' +
'.cf-node{cursor:pointer;transition:opacity .15s;}' +
'.cf-node.dim{opacity:.2;}' +
'.cf-node circle{stroke:#0b1020;stroke-width:2;}' +
'.cf-node.hov circle{stroke:#fff;stroke-width:3;}' +
'.cf-node.fac circle{stroke:' + COLORS.GOLD + ';stroke-width:3.5;}' +
'.cf-more circle{fill:' + COLORS.NAVY + ';stroke:#3f7bd6;stroke-width:2;stroke-dasharray:4 4;}' +
'.cf-more text{fill:#cfe0ff;font:700 13px/1 ui-monospace,monospace;text-anchor:middle;}' +
'.cf-label{fill:#cdd9f2;font:600 12px/1 -apple-system,sans-serif;text-anchor:middle;paint-order:stroke;' +
'stroke:#0b1020;stroke-width:3px;pointer-events:none;}' +
'.cf-center-glow{opacity:.18;}' +
'.cf-center circle{stroke:#ffd9bd;stroke-width:3;}' +
'.cf-center-name{fill:#fff;font:800 18px/1 -apple-system,sans-serif;text-anchor:middle;paint-order:stroke;' +
'stroke:' + COLORS.RED_DK + ';stroke-width:3px;}' +
'.cf-center-sub{fill:#ffe1cb;font:600 11px/1 ui-monospace,monospace;text-anchor:middle;}' +
'.cf-rings{position:absolute;left:10px;bottom:10px;z-index:25;background:rgba(8,12,24,.8);' +
'border:1px solid #1c2742;border-radius:8px;padding:7px 10px;font:600 10px/1.5 ui-monospace,monospace;color:#9fb3d9;}' +
'.cf-rings b{color:#dbe4f3;}' +
'.cf-tip{position:fixed;z-index:60;pointer-events:none;max-width:240px;background:#0e1730;border:1px solid #34508c;' +
'border-radius:9px;padding:9px 11px;box-shadow:0 6px 22px rgba(0,0,0,.55);font-size:11.5px;color:#dbe4f3;}' +
'.cf-tip strong{font-size:13px;color:#fff;}' +
'.cf-tip-sub{color:' + COLORS.GOLD + ';font:600 10px/1.3 ui-monospace,monospace;margin:2px 0 6px;}' +
'.cf-tip-row{display:flex;gap:6px;line-height:1.5;}' +
'.cf-tip-k{color:#7e90b6;min-width:54px;}' +
'@media (max-width:720px){.cf-meta{display:none;}}',
'cockpit-flowercell'
)
</script>
<template>
<div class="cpt-flower" @mousemove="onMove">
<!-- header -->
<div class="cf-bar">
<span class="cf-title">Bloom</span>
<span v-if="placed" class="cf-meta">
<b>{{ placed.center.country }}</b> · {{ placed.center.count }} unreached ·
<b>{{ placed.shown }}</b>/{{ placed.total }} senders
</span>
<button class="cf-bloom" type="button" @click="toggleBloom">
<span class="cf-dot"></span>{{ store.mode.value === 'flower' ? 'To map' : 'Bloom' }} ▸
</button>
</div>
<div class="cf-stage">
<!-- empty state -->
<div v-if="!placed" class="cf-empty">
Pick a red target on the map —<br>its reached neighbors bloom here as a flower.
</div>
<svg v-else class="cf-svg" :viewBox="'0 0 ' + VB + ' ' + VB" preserveAspectRatio="xMidYMid meet">
<!-- petal backdrops (fade in as it blooms) -->
<g v-if="morphT > 0.05">
<ellipse v-for="n in placed.nodes" :key="'pt-' + n.id"
v-show="n.ring === 'outer' && !n.isMore"
class="cf-petal"
:cx="petalXY(n)[0]" :cy="petalXY(n)[1]" rx="74" ry="38"
:fill="n.color"
:opacity="0.13 * morphT"
:transform="'rotate(' + n.ang + ' ' + petalXY(n)[0] + ' ' + petalXY(n)[1] + ')'" />
</g>
<!-- SEND spokes: center → each sender -->
<g>
<path v-for="n in placed.nodes" :key="'sp-' + n.id"
class="cf-spoke"
:class="{ lit: hoveredLang && isLit(n), dim: hoveredLang && !isLit(n) }"
:d="spokeD(n)"
:stroke-dasharray="dashStr(n)" />
</g>
<!-- same-language collaboration mesh -->
<g>
<path v-for="m in placed.mesh" :key="m.id"
class="cf-mesh"
:class="{ lit: hoveredLang && isMeshLit(m), dim: hoveredLang && !isMeshLit(m) }"
:d="meshD(m)" />
</g>
<!-- sender nodes + the "+N more" petal -->
<g>
<g v-for="n in placed.nodes" :key="'nd-' + n.id"
class="cf-node"
:class="{ dim: hoveredLang && !isLit(n), hov: hoveredId === n.id || store.hoveredId.value === n.id, fac: isFaceted(n), 'cf-more': n.isMore }"
@click.stop="onClick(n)"
@mouseenter="onEnter(n, $event)" @mouseleave="onLeave">
<template v-if="n.isMore">
<circle :cx="mx(n)" :cy="my(n)" :r="n.r" />
<text :x="mx(n)" :y="my(n) + 4">+{{ n.hidden }}</text>
</template>
<template v-else>
<circle :cx="mx(n)" :cy="my(n)" :r="n.r" :fill="n.color" />
<text v-if="hoveredId === n.id || n.r >= 12"
class="cf-label" :x="mx(n)" :y="my(n) + n.r + 14">{{ n.s.name }}</text>
</template>
</g>
</g>
<!-- center: the unreached red target -->
<g class="cf-center" style="pointer-events:none">
<circle class="cf-center-glow" :cx="mcx()" :cy="mcy()" :r="R_CTR + 26" :fill="placed.center.color" />
<circle :cx="mcx()" :cy="mcy()" :r="R_CTR" :fill="placed.center.color" />
<text class="cf-center-name" :x="mcx()" :y="mcy() - 1">{{ placed.center.country }}</text>
<text class="cf-center-sub" :x="mcx()" :y="mcy() + 16">{{ placed.center.count }} unreached</text>
</g>
</svg>
<!-- ring legend -->
<div v-if="placed" class="cf-rings">
<b>inner</b> same-country · <b>petals</b> cross-border<br>
navy = SEND · <span :style="{ color: COLORS.GOLD }">gold</span> = same-language mesh
</div>
</div>
<!-- tooltip -->
<div v-if="tip.show" class="cf-tip"
:style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }"
v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,363 @@
<script setup>
/**
* FlowerClusters.vue — the FLOWER-CLUSTERS schematic (UPGN-FLOWER-CLUSTERS).
*
* Cluster the DOXA unreached people-groups BY LANGUAGE → each cluster is a RED CORE
* (flower center) at the centroid of its member coords. Around each core, the GRANULAR
* green REACHED people-groups of the SAME language (real per-PG PPLR lat/lng) = petals,
* with navy connector lines (one core → many reached = a one-to-many flower).
*
* • Reds CLUSTERED (one core per language group) • Greens GRANULAR (every reached pin)
*
* THREE MODE CIRCLES (top): cluster-by Language-Family · Language · Dialect. Clicking one
* re-forms the cores at that granularity (FAMILY = fewer/bigger flowers, LANGUAGE = more,
* DIALECT = most) and the green petals reconnect.
*
* TAXONOMY SOURCE (read-only): @map/composables/legends/useLanguageFamilyLegendData.js — the
* research-map semantic-tree's family→language→dialect grouping (family derived from the
* language label via langFamilyByLanguage.json; base language = pre-comma; dialect = post-
* comma). We IMPORT it; we never edit the research map.
*
* 0-dep (vue + @map only), shadow-DOM safe. Mapbox via @map/useMapInstance; flower cores +
* green pins + links are GL layers (the basemap needs a token; the data layer is verifiable
* token-less via the gated host hook). Lives inside the doxa-upg-neighbors merged profile
* as the "Flowers" view; reuses the granular per-PG coords from UPGN-MERGE.
*/
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useLanguageFamilyLegendData } from '@map/composables/legends/useLanguageFamilyLegendData.js'
import langFamilyByLanguage from '@map/data/langFamilyByLanguage.json'
import bakedReached from '../data/reached-neighbors.json'
const C_RED = '#e11d48', C_RED_DK = '#9f1239', C_NAVY = '#002e6e', C_GOLD = '#f59e0b'
function reachedColor(pct) {
const p = +pct || 0
if (p >= 80) return '#0f7a3d'; if (p >= 40) return '#1f9d55'
if (p >= 15) return '#5cb874'; return '#9bcf9b'
}
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const cfg = computed(() => profileConfig?.value || {})
const fcRoot = ref(null)
const mapContainer = ref(null)
const appReady = ref(false)
const statusMsg = ref('Clustering the harvest by language…')
const mode = ref('language') // 'family' | 'language' | 'dialect'
const selectedKey = ref(null)
const doxaFeatures = ref([]) // normalized doxa unreached for the taxonomy composable
// ─── taxonomy (read-only research-map composable over the doxa unreached) ─────
// family = derived language family; language = base (pre-comma). For DIALECT (finest)
// we cluster by the FULL language label so EVERY PG forms its finest core — the
// composable's dialectRows only covers PGs with an explicit post-comma dialect (~272),
// which would make "dialect" FEWER than "language", not the most. Full-label grouping
// gives the most flowers (family < language < dialect), as the schematic intends.
const { rows: familyRows, languageRows } =
useLanguageFamilyLegendData(doxaFeatures, { showAllKnown: false })
function unwrapLabel(v) { return (v && typeof v === 'object') ? (v.label || v.value || '') : (v || '') }
const dialectRows = computed(() => {
const m = new Map()
for (const it of doxaFeatures.value) {
const lbl = (unwrapLabel(it.primary_language) || '— Unknown —').trim()
const lat = parseFloat(it.latitude), lng = parseFloat(it.longitude)
let e = m.get(lbl)
if (!e) { e = { key: 'full:' + lbl, label: lbl, color: C_RED, peopleGroupCount: 0, population: 0, coords: [], originalLabels: [lbl] }; m.set(lbl, e) }
e.peopleGroupCount++
e.population += (parseInt(it.population, 10) || 0)
if (isFinite(lat) && isFinite(lng)) e.coords.push([lng, lat])
}
return [...m.values()].sort((a, b) => b.peopleGroupCount - a.peopleGroupCount)
})
// ─── language helpers — MIRROR the composable's pure logic so we can bucket the
// REACHED senders the same way (the composable only buckets the doxa side). ─
function baseLangOf(label) {
if (!label || typeof label !== 'string') return ''
const c = label.indexOf(','); return (c > 0 ? label.slice(0, c) : label).trim()
}
function dialectFullOf(label) { return (label || '').trim() }
function familyOf(label) {
if (!label || typeof label !== 'string') return 'Unknown'
const parts = label.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) {
const full = [...parts].reverse().join(' ')
if (langFamilyByLanguage[full]) return langFamilyByLanguage[full]
if (parts.length >= 3) { const two = [parts[1], parts[0]].join(' '); if (langFamilyByLanguage[two]) return langFamilyByLanguage[two] }
}
if (langFamilyByLanguage[label]) return langFamilyByLanguage[label]
const stripped = label.replace(/\s*\(.*?\)\s*$/, '').trim()
if (stripped !== label && langFamilyByLanguage[stripped]) return langFamilyByLanguage[stripped]
if (/ sign language$/i.test(label)) return 'Sign Language'
return 'Unknown'
}
// ─── flat reached senders (real coords) + their taxonomy keys ─────────────────
const reachedAll = computed(() => {
const out = []
const seen = new Set()
for (const t of Object.values(bakedReached.countries || {})) {
for (const n of (t.neighbors || [])) {
const lat = Number(n.lat), lng = Number(n.lng)
if (!isFinite(lat) || !isFinite(lng)) continue
const id = (n.code || n.name) + '|' + n.country
if (seen.has(id)) continue
seen.add(id)
out.push({
id, name: n.name, country: n.country, lang: n.lang, pct: n.pct_christian || 0,
pop: n.pop || 0, lat, lng,
family: familyOf(n.lang), base: baseLangOf(n.lang), full: dialectFullOf(n.lang)
})
}
}
return out
})
// ─── CORES for the active mode (red clusters) ─────────────────────────────────
// Each core: { key, label, level, count, lat, lng, color, matchKeys:Set } where matchKeys
// is the set of values a reached sender must hit (by family/base/full) to be a petal.
function centroid(coords) {
let x = 0, y = 0, n = 0
for (const c of coords || []) { if (Array.isArray(c)) { x += c[0]; y += c[1]; n++ } }
return n ? [x / n, y / n] : null
}
const cores = computed(() => {
const m = mode.value
const src = m === 'family' ? familyRows.value : m === 'dialect' ? dialectRows.value : languageRows.value
const out = []
for (const r of src) {
if (!r.peopleGroupCount) continue
const c = centroid(r.coords)
if (!c) continue
let matchKeys
if (m === 'family') matchKeys = { kind: 'family', set: new Set([r.label]) }
else if (m === 'dialect') matchKeys = { kind: 'full', set: new Set(r.originalLabels || [r.label]) }
else matchKeys = { kind: 'base', set: new Set([r.label]) }
out.push({
key: (r.key || r.label), label: r.label, level: m,
count: r.peopleGroupCount, pop: r.population || 0,
lng: c[0], lat: c[1], color: r.color || C_RED, match: matchKeys
})
}
out.sort((a, b) => b.count - a.count)
return out
})
// reached petals grouped per core (granular). Returns { greens:[{...,coreKey}], links:[{from,to}] }
const bloom = computed(() => {
const greens = [], links = []
const reached = reachedAll.value
for (const core of cores.value) {
const { kind, set } = core.match
for (const r of reached) {
const v = kind === 'family' ? r.family : kind === 'full' ? r.full : r.base
if (!set.has(v)) continue
greens.push({ ...r, coreKey: core.key, color: reachedColor(r.pct) })
links.push({ coreKey: core.key, from: [r.lng, r.lat], to: [core.lng, core.lat] })
}
}
return { greens, links }
})
const stats = computed(() => ({
cores: cores.value.length,
unreached: cores.value.reduce((s, c) => s + c.count, 0),
petals: bloom.value.greens.length
}))
const selectedCore = computed(() => cores.value.find(c => c.key === selectedKey.value) || null)
// ─── Map ──────────────────────────────────────────────────────────────────────
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken?.value || cfg.value.tk || '',
style: cfg.value.style ?? 'mapbox://styles/mapbox/light-v11',
center: cfg.value.center ?? [55, 22], zoom: cfg.value.zoom ?? 1.7, minZoom: 1, maxZoom: 12
})
const SRC_CORE='fc-core-src', SRC_GREEN='fc-green-src', SRC_LINK='fc-link-src'
const L_CORE='fc-core', L_CORE_LBL='fc-core-lbl', L_GREEN='fc-green', L_LINK='fc-link'
const fc = (features) => ({ type: 'FeatureCollection', features })
function coreFeatures() {
return fc(cores.value.map(c => ({
type: 'Feature', geometry: { type: 'Point', coordinates: [c.lng, c.lat] },
properties: { key: c.key, label: c.label + ' (' + c.count + ')', count: c.count, color: c.color }
})))
}
function greenFeatures() {
return fc(bloom.value.greens.map(g => ({
type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] },
properties: { name: g.name, country: g.country, lang: g.lang, pct: g.pct, color: g.color, coreKey: g.coreKey }
})))
}
function linkFeatures() {
return fc(bloom.value.links.map(l => ({
type: 'Feature', geometry: { type: 'LineString', coordinates: [l.from, l.to] }, properties: { coreKey: l.coreKey }
})))
}
function setSrc(m, id, data) { const s = m.getSource(id); if (s) s.setData(data); else m.addSource(id, { type: 'geojson', data }) }
function paint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
setSrc(m, SRC_LINK, linkFeatures())
setSrc(m, SRC_GREEN, greenFeatures())
setSrc(m, SRC_CORE, coreFeatures())
if (!m.getLayer(L_LINK)) m.addLayer({
id: L_LINK, type: 'line', source: SRC_LINK,
paint: { 'line-color': C_NAVY, 'line-width': 0.6, 'line-opacity': 0.12 }
})
if (!m.getLayer(L_GREEN)) m.addLayer({
id: L_GREEN, type: 'circle', source: SRC_GREEN,
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 1.4, 6, 3], 'circle-color': ['get', 'color'],
'circle-stroke-width': 0.4, 'circle-stroke-color': '#fff', 'circle-opacity': 0.82 }
})
if (!m.getLayer(L_CORE)) m.addLayer({
id: L_CORE, type: 'circle', source: SRC_CORE,
paint: { 'circle-radius': ['interpolate', ['linear'], ['get', 'count'], 1, 8, 25, 18, 120, 30],
'circle-color': C_RED, 'circle-opacity': 0.5, 'circle-stroke-width': 2, 'circle-stroke-color': C_RED_DK }
})
if (!m.getLayer(L_CORE_LBL)) m.addLayer({
id: L_CORE_LBL, type: 'symbol', source: SRC_CORE, minzoom: 2.2,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.4], 'text-anchor': 'top',
'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#fff', 'text-halo-width': 1.4 }
})
applySelection()
}
function applySelection() {
const m = map.value
if (!m || !m.getLayer(L_LINK)) return
const k = selectedKey.value
// selected core's petals bright; otherwise the whole mesh stays faint
m.setPaintProperty(L_LINK, 'line-opacity', k ? ['case', ['==', ['get', 'coreKey'], k], 0.7, 0.04] : 0.12)
m.setPaintProperty(L_LINK, 'line-color', k ? ['case', ['==', ['get', 'coreKey'], k], C_GOLD, C_NAVY] : C_NAVY)
if (m.getLayer(L_GREEN)) m.setPaintProperty(L_GREEN, 'circle-opacity',
k ? ['case', ['==', ['get', 'coreKey'], k], 0.95, 0.18] : 0.82)
// item 5: clicking a flower (core) DIMS all other flowers to ~0.4 (in the 0.3–0.5
// band), labels kept (L_CORE_LBL is a separate symbol layer, unaffected).
if (m.getLayer(L_CORE)) m.setPaintProperty(L_CORE, 'circle-opacity',
k ? ['case', ['==', ['get', 'key'], k], 0.9, 0.4] : 0.5)
}
function selectCore(key) {
selectedKey.value = (selectedKey.value === key) ? null : key
applySelection()
const c = selectedCore.value, m = map.value
if (c && m) {
const pts = bloom.value.links.filter(l => l.coreKey === c.key).map(l => l.from).concat([[c.lng, c.lat]])
let minLng = c.lng, maxLng = c.lng, minLat = c.lat, maxLat = c.lat
for (const p of pts) { minLng = Math.min(minLng, p[0]); maxLng = Math.max(maxLng, p[0]); minLat = Math.min(minLat, p[1]); maxLat = Math.max(maxLat, p[1]) }
try { m.stop(); m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 90, maxZoom: 5.5, duration: 700 }) } catch (_) {}
}
}
function setMode(md) { if (mode.value !== md) { mode.value = md; selectedKey.value = null } }
watch([mode, () => doxaFeatures.value.length], () => { if (appReady.value) paint() })
// ─── doxa fetch (live unreached) ──────────────────────────────────────────────
async function fetchDoxa() {
const base = cfg.value.apiBase || (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL)) || 'https://pray.doxa.life'
const fields = 'slug,name,latitude,longitude,country_code,primary_language,wagf_block'
const r = await fetch(base.replace(/\/?$/, '') + '/api/people-groups/list?fields=' + fields + '&lang=en', { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
const posts = j.posts || j.data || j.records || j.results || (Array.isArray(j) ? j : [])
// shape for the taxonomy composable: it reads item.id + item.primary_language/latitude/longitude/population
return posts.map(p => ({ ...p, id: p.slug }))
}
onMounted(async () => {
await initializeMap()
const m = map.value
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
try { doxaFeatures.value = await fetchDoxa() }
catch (e) { statusMsg.value = 'Live doxa API unreachable — ' + e.message; appReady.value = true; return }
appReady.value = true
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try { (fcRoot.value?.getRootNode?.()?.host || window).__flowers = { get mode() { return mode.value }, setMode, get stats() { return stats.value }, selectCore } } catch (_) {}
}
const wire = () => {
paint()
m.on('click', L_CORE, (e) => { const f = e.features?.[0]; if (f) selectCore(f.properties.key) })
m.on('mouseenter', L_CORE, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', L_CORE, () => { m.getCanvas().style.cursor = '' })
}
if (m?.isStyleLoaded?.()) wire(); else m?.once?.('load', wire)
m?.once?.('idle', () => { if (appReady.value) paint() })
})
onBeforeUnmount(() => { destroy() })
useShadowStyles(`
.fc-root { position:absolute; inset:0; overflow:hidden; background:#ffffff; color:#1f2937; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.fc-map { position:absolute; inset:0; }
.fc-loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:${C_NAVY}; font:13px/1 ui-monospace,monospace; z-index:5; }
/* the 3 MODE CIRCLES at the top */
.fc-modes { position:absolute; top:12px; left:50%; transform:translateX(-50%); z-index:30; display:flex; gap:14px; align-items:flex-start; }
.fc-mode { display:flex; flex-direction:column; align-items:center; gap:5px; cursor:pointer; background:none; border:0; }
.fc-circle { width:54px; height:54px; border-radius:50%; background:#fff; border:3px solid #d1d5db; display:flex;
align-items:center; justify-content:center; font:800 11px/1 ui-monospace,monospace; color:#9ca3af; box-shadow:0 2px 10px rgba(0,0,0,.12); transition:all .15s; }
.fc-mode.on .fc-circle { border-color:${C_RED}; color:${C_RED_DK}; transform:scale(1.08); box-shadow:0 4px 16px rgba(225,29,72,.3); }
.fc-mode:hover .fc-circle { border-color:${C_RED}; }
.fc-mode-lbl { font:700 10px/1 ui-monospace,monospace; letter-spacing:.04em; text-transform:uppercase; color:#6b7280; }
.fc-mode.on .fc-mode-lbl { color:${C_RED_DK}; }
.fc-mode-sz { font:600 9px/1 ui-monospace,monospace; color:#9ca3af; }
.fc-stats { position:absolute; top:14px; right:12px; z-index:30; background:rgba(255,255,255,.95); border:1px solid #d1d5db;
border-radius:8px; padding:7px 11px; font:600 11px/1.5 ui-monospace,monospace; color:#374151; text-align:right; }
.fc-stats b { color:${C_RED_DK}; }
.fc-legend { position:absolute; left:12px; bottom:30px; z-index:25; background:rgba(255,255,255,.95); border:1px solid #d1d5db;
border-radius:10px; padding:9px 12px; max-width:250px; box-shadow:0 4px 14px rgba(0,0,0,.12); font-size:11px; color:#374151; }
.fc-legend h4 { font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; color:#6b7280; margin-bottom:6px; }
.fc-lrow { display:flex; align-items:center; gap:8px; padding:2px 0; }
.fc-dot { width:12px; height:12px; border-radius:50%; border:1px solid rgba(0,0,0,.25); flex:0 0 auto; }
.fc-footer { position:absolute; left:0; right:0; bottom:0; z-index:20; background:${C_NAVY}; color:#dbe4f3;
font:10px/1.4 ui-monospace,monospace; padding:5px 12px; display:flex; flex-wrap:wrap; gap:3px 14px; }
.fc-footer b { color:#fff; }
`, 'flower-clusters')
const MODES = [
{ key: 'family', label: 'Family', sz: 'fewer · bigger' },
{ key: 'language', label: 'Language', sz: 'more' },
{ key: 'dialect', label: 'Dialect', sz: 'most' }
]
</script>
<template>
<div ref="fcRoot" class="fc-root">
<div ref="mapContainer" class="fc-map"></div>
<div v-if="!appReady" class="fc-loading">{{ statusMsg }}</div>
<!-- the 3 cluster-by MODE CIRCLES -->
<div class="fc-modes" role="tablist" aria-label="Cluster by">
<button v-for="md in MODES" :key="md.key" class="fc-mode" :class="{ on: mode === md.key }"
role="tab" :aria-selected="mode === md.key" @click="setMode(md.key)">
<span class="fc-circle">{{ md.label.slice(0,3).toUpperCase() }}</span>
<span class="fc-mode-lbl">{{ md.label }}</span>
<span class="fc-mode-sz">{{ md.sz }}</span>
</button>
</div>
<div v-if="appReady" class="fc-stats">
<b>{{ stats.cores }}</b> flowers · <b>{{ stats.unreached.toLocaleString() }}</b> unreached<br>
<b>{{ stats.petals.toLocaleString() }}</b> reached petals · by {{ mode }}
</div>
<div v-if="appReady" class="fc-legend">
<h4>Flower clusters</h4>
<div class="fc-lrow"><span class="fc-dot" :style="{ background: C_RED }"></span> Unreached language cluster (core, size = #PGs)</div>
<div class="fc-lrow"><span class="fc-dot" :style="{ background: reachedColor(80) }"></span> Reached neighbor (granular, real coord)</div>
<div class="fc-lrow" style="color:#6b7280;font-size:10px;margin-top:4px">click a top circle to re-cluster · click a core to bloom its petals</div>
</div>
<div v-if="appReady" class="fc-footer">
<span><b>Flower-clusters</b> red cores (clustered) → granular green reached petals (same language)</span>
<span>taxonomy = research-map semantic tree (family→language→dialect)</span>
<span>unreached = LIVE pray.doxa.life · reached = baked PPLR</span>
<span>Luke 10:2</span>
</div>
</div>
</template>

View file

@ -0,0 +1,400 @@
<script setup>
/**
* ListCell.vue — cockpit SENDER LIST cell (the send-board mobilization column).
*
* The right-hand roster of the lower row. Reads the shared cockpit store and renders
* the reached senders for the focused red target as a sortable, checkbox-selectable
* send list:
* - Header: "Send N reached neighbors INTO [Target] — they speak [language]"
* (N = TRUE rendered count = store.listRows.length).
* - SENDERS-AVAILABLE badge from focusedTarget.senderCount (+ honest hidden count).
* - Rows: quantile swatch · name · country · lang(rol3) · %Christian · pop ·
* bridge chip (LINE STYLE, never color) · religion chip · tap-to-pin tooltip.
* - Sort toggle (mobilization default · %Christian · pop · name · bridge ·
* "Frontier first").
* - "Build send list" checkboxes → Export-Selection → CSV (store.exportSelectionCSV)
* and a navigator.clipboard copy with the textarea+execCommand fallback (the
* upg-100-list.vue formatClipboard / legacyCopy pattern).
* - Two-way link: hover/click a row → store hover (pulses the map+flower node/arc);
* a map/flower hover scrolls the matching row into view.
*
* 0-DEPENDENCY (vue + @map only). Shadow-DOM safe: ALL CSS via useShadowStyles
* (a child's own <style> is dropped by defineCustomElement). Color/size/bin ONLY via
* the exported helpers; the locked color contract is honored.
*/
import { ref, computed, watch, nextTick } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import {
useCockpitStore, COLORS, GREEN_BIN_LABELS,
reachedColor, quantileBin, bridgeDash, fmtPop
} from './useCockpit.js'
const store = useCockpitStore()
// ── store-derived reads (always via .value — store is a plain object) ─────────
const rows = computed(() => store.listRows.value)
const focusedTarget = computed(() => store.focusedTarget.value)
const counts = computed(() => store.counts.value)
const sortKey = computed(() => store.sortKey.value)
// header language: what the unreached target speaks (the bridge the senders cross)
const targetLang = computed(() => {
const c = focusedTarget.value
if (!c) return ''
const langs = c.target_top_langs && c.target_top_langs.length ? c.target_top_langs : (c.langs || [])
return langs[0] || ''
})
const headerTitle = computed(() => {
const c = focusedTarget.value
if (!c) return 'Pick a red target to build a send list'
const n = rows.value.length
const tail = targetLang.value ? ' — they speak ' + targetLang.value : ''
return 'Send ' + n.toLocaleString() + ' reached neighbor' + (n === 1 ? '' : 's') +
' INTO ' + c.country + tail
})
// SENDERS-AVAILABLE badge: the full roster size for the target (pre-filter), with the
// honest hidden count when facets/search have narrowed the rendered rows.
const availableTotal = computed(() => focusedTarget.value ? (focusedTarget.value.senderCount || 0) : 0)
const hiddenCount = computed(() => counts.value.hidden || 0)
// ── sort options ──────────────────────────────────────────────────────────────
const SORTS = [
{ key: 'mobilization', label: 'Mobilization' },
{ key: 'pct', label: '%Christian' },
{ key: 'pop', label: 'Population' },
{ key: 'bridge', label: 'Bridge' },
{ key: 'name', label: 'Name' },
{ key: 'frontier', label: 'Frontier first' }
]
function pickSort(key) { store.setSort(key) }
// ── batch selection ("Build send list") ──────────────────────────────────────
const selectedIds = ref(new Set())
function toggleRow(id) {
const s = new Set(selectedIds.value)
if (s.has(id)) s.delete(id); else s.add(id)
selectedIds.value = s
copyFlash.value = ''
}
const selectedRows = computed(() => rows.value.filter(r => selectedIds.value.has(r.id)))
const selCount = computed(() => selectedRows.value.length)
const allSelected = computed(() =>
rows.value.length > 0 && rows.value.every(r => selectedIds.value.has(r.id)))
function toggleAll() {
selectedIds.value = allSelected.value ? new Set() : new Set(rows.value.map(r => r.id))
copyFlash.value = ''
}
function clearSelection() { selectedIds.value = new Set(); copyFlash.value = '' }
// rows to act on: the explicit selection if any, else the whole filtered list
const exportRows = computed(() => selCount.value ? selectedRows.value : rows.value)
// ── Export → CSV (store does the injection-safe quoting + provenance + download) ─
function exportCsv() {
if (!exportRows.value.length) return
store.exportSelectionCSV(exportRows.value)
copyFlash.value = 'CSV downloaded (' + exportRows.value.length + ')'
flashClear()
}
// ── Copy (clipboard) — reuse the upg-100-list.vue formatClipboard / legacyCopy path ─
const copyFlash = ref('')
let flashTimer = null
function flashClear() {
if (flashTimer) clearTimeout(flashTimer)
flashTimer = setTimeout(() => { copyFlash.value = '' }, 1900)
}
function formatClipboard(list) {
const c = focusedTarget.value
const head = 'Send list — ' + (c ? c.country : 'all targets') +
(targetLang.value ? ' (' + targetLang.value + ')' : '') +
' · ' + list.length + ' senders'
const lines = list.map(r =>
r.name + ' · ' + r.country + ' · ' + r.lang + (r.rol3 ? ' (' + r.rol3 + ')' : '') +
' · ' + Math.round(r.pct) + '% Christian · ' + fmtPop(r.pop) + ' · ' + r.bridge)
return head + '\n' + lines.join('\n') + '\n'
}
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 }
}
async function copyList() {
const list = exportRows.value
if (!list.length) return
const text = formatClipboard(list)
let ok = false
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text); ok = true
} else { ok = legacyCopy(text) }
} catch (_) { ok = legacyCopy(text) }
copyFlash.value = ok ? 'Copied (' + list.length + ')' : 'Copy failed — press Ctrl/Cmd-C'
flashClear()
}
// ── two-way row ↔ map/flower hover link ──────────────────────────────────────
// The store has no dedicated hover field yet (reported for integration). We call/read
// defensively so the link lights up the moment integration adds `hoveredSenderId`
// (ref) + `setHover(id)` (action), and never crashes in the meantime.
function setHover(id) {
if (typeof store.setHover === 'function') store.setHover(id)
}
const hoveredId = computed(() =>
store.hoveredSenderId && typeof store.hoveredSenderId === 'object'
? store.hoveredSenderId.value : null)
const rowsEl = ref(null)
// when a map/flower hover changes the store hover, scroll the matching row into view
watch(hoveredId, (id) => {
if (!id || !rowsEl.value) return
nextTick(() => {
const el = rowsEl.value && rowsEl.value.querySelector('[data-sid="' + cssEscape(id) + '"]')
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' })
})
})
function cssEscape(s) { return String(s).replace(/["\\]/g, '\\$&') }
// row click → pin the hover (focus the node/arc); a re-click clears it
function onRowClick(id) { setHover(hoveredId.value === id ? null : id) }
// ── tap-to-pin tooltip (full sender provenance) ──────────────────────────────
const pinnedInfo = ref(null)
function toggleInfo(id) { pinnedInfo.value = pinnedInfo.value === id ? null : id }
// ── presentation helpers (all binning/coloring via the locked exports) ───────
function swatchColor(pct) { return reachedColor(pct) }
function binLabel(pct) { return GREEN_BIN_LABELS[quantileBin(pct)] }
function bridgeLineStyle(bridge) {
const d = bridgeDash(bridge)
if (d === null) return 'solid'
return d[0] === 4 ? 'dashed' : 'dotted'
}
function pctLabel(pct) { return Math.round(+pct || 0) + '%' }
// ─────────────────────────────────────────────────────────────────────────────
// SHADOW-DOM CSS — single template literal, color consts interpolated, NO nested
// backticks (a stray backtick would close the literal and blank the stylesheet).
// ─────────────────────────────────────────────────────────────────────────────
useShadowStyles(`
.cpt-list{position:absolute;inset:0;display:flex;flex-direction:column;background:#ffffff;
color:${COLORS.NAVY};font:400 12px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
overflow:hidden}
.cpt-list *{box-sizing:border-box}
/* header */
.cpt-l-head{flex:0 0 auto;padding:8px 10px 6px;border-bottom:1px solid #e6eaf2;background:#fbfcfe}
.cpt-l-title{font-size:12.5px;font-weight:700;color:${COLORS.NAVY};line-height:1.35}
.cpt-l-sub{margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;
font-size:10.5px;color:#5b6577}
.cpt-l-badge{display:inline-flex;align-items:center;gap:5px;font-weight:700;font-size:10px;
letter-spacing:.03em;text-transform:uppercase;color:${COLORS.NAVY};
background:rgba(0,46,110,.08);border:1px solid rgba(0,46,110,.18);
padding:2px 7px;border-radius:999px}
.cpt-l-badge b{font-size:11px}
.cpt-l-hidden{color:#9aa3b2}
/* sort row */
.cpt-l-sorts{display:flex;gap:4px;flex-wrap:wrap;margin-top:7px}
.cpt-l-sort{cursor:pointer;border:1px solid #d6dceb;background:#fff;color:#4a5468;
font:600 10px/1 ui-monospace,monospace;padding:5px 8px;border-radius:7px;white-space:nowrap}
.cpt-l-sort:hover{border-color:${COLORS.NAVY};color:${COLORS.NAVY}}
.cpt-l-sort.active{background:${COLORS.NAVY};border-color:${COLORS.NAVY};color:#fff}
.cpt-l-sort.frontier.active{background:${COLORS.GOLD};border-color:${COLORS.GOLD};color:#3a2a00}
/* action bar */
.cpt-l-actions{flex:0 0 auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;
padding:6px 10px;border-bottom:1px solid #eef1f7;background:#fff}
.cpt-l-selall{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:#5b6577;
cursor:pointer;user-select:none}
.cpt-l-selcount{font-weight:700;color:${COLORS.NAVY}}
.cpt-l-btn{margin-left:0;cursor:pointer;border:1px solid #cdd5e6;background:#fff;
color:${COLORS.NAVY};font:600 11px/1 ui-monospace,monospace;padding:7px 10px;border-radius:8px}
.cpt-l-btn:hover{border-color:${COLORS.NAVY};background:rgba(0,46,110,.06)}
.cpt-l-btn:disabled{opacity:.45;cursor:not-allowed}
.cpt-l-btn.primary{background:${COLORS.NAVY};border-color:${COLORS.NAVY};color:#fff}
.cpt-l-btn.primary:hover{background:#013a8a}
.cpt-l-btn.ghost{color:#7b8498;border-color:#e0e5f0}
.cpt-l-spacer{flex:1 1 auto}
.cpt-l-flash{font-size:10px;color:#0f7a3d;min-height:12px;white-space:nowrap}
.cpt-l-flash.warn{color:${COLORS.RED}}
/* rows */
.cpt-l-rows{flex:1 1 auto;overflow:auto;padding:2px 0}
.cpt-l-row{display:grid;grid-template-columns:auto 12px 1fr auto;align-items:center;gap:8px;
padding:6px 10px;border-bottom:1px solid #f1f3f8;cursor:pointer;position:relative}
.cpt-l-row:hover,.cpt-l-row.hovered{background:rgba(245,158,11,.10)}
.cpt-l-row.hovered{box-shadow:inset 3px 0 0 0 ${COLORS.GOLD}}
.cpt-l-row.picked{background:rgba(0,46,110,.05)}
.cpt-l-chk{width:15px;height:15px;cursor:pointer;accent-color:${COLORS.NAVY};margin:0}
.cpt-l-swatch{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.18)}
.cpt-l-body{min-width:0}
.cpt-l-name{font-weight:700;font-size:12px;color:${COLORS.NAVY};
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cpt-l-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:2px;
font-size:10px;color:#6b7587}
.cpt-l-meta .sep{color:#c4ccda}
.cpt-l-lang{font-family:ui-monospace,monospace}
.cpt-l-rol3{color:#9aa3b2}
.cpt-l-right{display:flex;align-items:center;gap:8px;justify-self:end;white-space:nowrap}
.cpt-l-pct{font-weight:700;font-size:11.5px;font-family:ui-monospace,monospace}
.cpt-l-pop{font-size:10px;color:#6b7587;font-family:ui-monospace,monospace;min-width:34px;text-align:right}
/* chips */
.cpt-l-chip{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;
padding:1px 6px;border-radius:999px;border:1px solid #dde3ef;color:#4a5468;background:#f7f9fc;
white-space:nowrap}
.cpt-l-chip.relig{border-color:#e3dcf0;background:#f6f3fc;color:#5b4a86}
.cpt-l-bridgeline{display:inline-block;width:14px;height:0;
border-top:2px solid ${COLORS.NAVY}}
.cpt-l-info{flex:0 0 auto;width:16px;height:16px;border-radius:50%;cursor:pointer;
border:1px solid #d6dceb;background:#fff;color:#7b8498;font:700 10px/14px ui-monospace,monospace;
text-align:center}
.cpt-l-info:hover,.cpt-l-info.on{border-color:${COLORS.NAVY};color:${COLORS.NAVY}}
/* pinned tooltip */
.cpt-l-tip{grid-column:1 / -1;margin-top:6px;padding:7px 9px;border-radius:8px;
background:${COLORS.NAVY};color:#eef3fb;font-size:10px;line-height:1.5}
.cpt-l-tip dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:2px 10px}
.cpt-l-tip dt{color:#9fb6db;font-weight:600}
.cpt-l-tip dd{margin:0;color:#eef3fb;word-break:break-word}
/* empty / placeholder states */
.cpt-l-empty{flex:1 1 auto;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:6px;text-align:center;color:#8a93a5;padding:24px}
.cpt-l-empty .big{font-size:13px;font-weight:700;color:${COLORS.NAVY}}
.cpt-l-empty .sm{font-size:11px}
/* container-query polish for narrow frames */
@container (max-width: 420px){
.cpt-l-pop{display:none}
.cpt-l-meta .relig{display:none}
}
`, 'cockpit-listcell')
</script>
<template>
<div class="cpt-list" style="container-type:inline-size">
<!-- header -->
<div class="cpt-l-head">
<div class="cpt-l-title">{{ headerTitle }}</div>
<div class="cpt-l-sub" v-if="focusedTarget">
<span class="cpt-l-badge">
<b>{{ availableTotal.toLocaleString() }}</b> senders available
</span>
<span v-if="hiddenCount" class="cpt-l-hidden">· {{ hiddenCount.toLocaleString() }} hidden by filters</span>
</div>
<div class="cpt-l-sorts" v-if="focusedTarget">
<button
v-for="s in SORTS"
:key="s.key"
class="cpt-l-sort"
:class="{ active: sortKey === s.key, frontier: s.key === 'frontier' }"
type="button"
@click="pickSort(s.key)"
>{{ s.label }}</button>
</div>
</div>
<!-- action bar -->
<div class="cpt-l-actions" v-if="focusedTarget && rows.length">
<label class="cpt-l-selall">
<input class="cpt-l-chk" type="checkbox" :checked="allSelected" @change="toggleAll" />
Build send list
<span v-if="selCount" class="cpt-l-selcount">({{ selCount }})</span>
</label>
<span class="cpt-l-spacer"></span>
<span class="cpt-l-flash" :class="{ warn: copyFlash.indexOf('fail') !== -1 }">{{ copyFlash }}</span>
<button class="cpt-l-btn ghost" type="button" v-if="selCount" @click="clearSelection">Clear</button>
<button class="cpt-l-btn" type="button" :disabled="!exportRows.length" @click="copyList">Copy</button>
<button class="cpt-l-btn primary" type="button" :disabled="!exportRows.length" @click="exportCsv">
Export {{ selCount ? 'selection' : 'all' }} · CSV
</button>
</div>
<!-- rows -->
<div class="cpt-l-rows" ref="rowsEl" v-if="focusedTarget && rows.length">
<div
v-for="r in rows"
:key="r.id"
class="cpt-l-row"
:class="{ hovered: hoveredId === r.id, picked: selectedIds.has(r.id) }"
:data-sid="r.id"
@mouseenter="setHover(r.id)"
@mouseleave="setHover(null)"
@click="onRowClick(r.id)"
>
<input
class="cpt-l-chk"
type="checkbox"
:checked="selectedIds.has(r.id)"
@click.stop
@change="toggleRow(r.id)"
/>
<span class="cpt-l-swatch" :style="{ background: swatchColor(r.pct) }" :title="binLabel(r.pct)"></span>
<div class="cpt-l-body">
<div class="cpt-l-name" :title="r.name">{{ r.name }}</div>
<div class="cpt-l-meta">
<span>{{ r.country }}</span>
<span class="sep">·</span>
<span class="cpt-l-lang">{{ r.lang }}<span v-if="r.rol3" class="cpt-l-rol3"> ({{ r.rol3 }})</span></span>
<span class="cpt-l-chip" :title="'bridge: ' + r.bridge">
<span class="cpt-l-bridgeline" :style="{ borderTopStyle: bridgeLineStyle(r.bridge) }"></span>
{{ r.bridge }}
</span>
<span class="cpt-l-chip relig" :title="r.religion_bg">{{ r.religionFamily }}</span>
</div>
</div>
<div class="cpt-l-right">
<span class="cpt-l-pct" :style="{ color: swatchColor(r.pct) }">{{ pctLabel(r.pct) }}</span>
<span class="cpt-l-pop">{{ fmtPop(r.pop) }}</span>
<button
class="cpt-l-info"
:class="{ on: pinnedInfo === r.id }"
type="button"
title="Details"
@click.stop="toggleInfo(r.id)"
>i</button>
</div>
<!-- tap-to-pin tooltip -->
<div class="cpt-l-tip" v-if="pinnedInfo === r.id" @click.stop>
<dl>
<dt>People group</dt><dd>{{ r.name }}</dd>
<dt>Country</dt><dd>{{ r.country }}</dd>
<dt>Language</dt><dd>{{ r.lang }}<span v-if="r.rol3"> · {{ r.rol3 }}</span></dd>
<dt>%Christian</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
<dt>Population</dt><dd>{{ fmtPop(r.pop) }}</dd>
<dt>Bridge</dt><dd>{{ r.bridge }}</dd>
<dt>Adjacency</dt><dd>{{ r.adjacency }}</dd>
<dt>Religion</dt><dd>{{ r.religion_bg || r.religionFamily }}</dd>
<dt v-if="r.code">Code</dt><dd v-if="r.code">{{ r.code }}</dd>
</dl>
</div>
</div>
</div>
<!-- empty: target focused but no rows match filters -->
<div class="cpt-l-empty" v-else-if="focusedTarget">
<div class="big">No senders match the current filters</div>
<div class="sm" v-if="hiddenCount">{{ hiddenCount.toLocaleString() }} senders hidden — clear a facet to see them</div>
<div class="sm" v-else>This target has no reached neighbors in the baked set yet</div>
</div>
<!-- placeholder: nothing focused -->
<div class="cpt-l-empty" v-else>
<div class="big">Pick a red target</div>
<div class="sm">{{ counts.clusters.toLocaleString() }} unreached clusters · {{ counts.senders.toLocaleString() }} reached senders nearby</div>
<div class="sm">Tap a red bubble on the map to build its send list.</div>
</div>
</div>
</template>

View file

@ -0,0 +1,481 @@
<script setup>
/**
* MapCell.vue — cockpit MAP cell (the HERO map · "lang-cluster done right").
*
* DEFAULT reds-only: every unreached CLUSTER as a labelled bubble at its real
* centroid, sized by target_upg count, collision-thinned labels, SOLID filled =
* has >=1 sender, HOLLOW ring = frontier (0 senders, never blank).
* SELECT store.selectTarget(id): camera frames it; OTHER reds dim to 0.5 (labels
* kept); its greens fade in at REAL lat/lng (5 quantile bins, or religion when
* colorBy='religion'); navy great-circle SEND arcs (marching-dash, line-style by
* bridge) from each green INTO the red center; same-country senders aggregate into
* ONE 'in-country pool (N)' node.
* PROVINCE store.provinceDrill: overlay admin-1 boundaries (IN/CN sub-division).
*
* READS the shared cockpit store; DRIVES interaction via store actions only — it owns
* no filter state. 0-DEP (vue + @map). SHADOW-DOM SAFE: ALL CSS via useShadowStyles
* (this child's own <style> would be dropped by defineCustomElement).
*/
import { inject, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useCockpitStore, COLORS, redColor, reachedColor, logRadius, fmtPop } from './useCockpit.js'
const store = useCockpitStore()
// ─── inject() from ProfileLoader (same contract as the sibling profiles) ──────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const cfg = computed(() => profileConfig?.value || {})
const tokenMissing = computed(() => !(mapboxToken?.value || cfg.value.tk))
// ─── refs ─────────────────────────────────────────────────────────────────────
const mcRoot = ref(null)
const mapContainer = ref(null)
const ready = ref(false)
const tip = ref({ show: false, pinned: false, x: 0, y: 0, html: '' })
const STROKE_GREEN = COLORS.GREEN_BINS[3] // ring on reds that have senders
// ─── source / layer ids ────────────────────────────────────────────────────────
const SRC_RED = 'cpt-red-src', SRC_GREEN = 'cpt-green-src', SRC_ARC = 'cpt-arc-src'
const L_RED = 'cpt-red', L_RED_LBL = 'cpt-red-lbl'
const L_GREEN = 'cpt-green', L_GREEN_LBL = 'cpt-green-lbl'
const L_ARC_SOLID = 'cpt-arc-solid', L_ARC_DASH = 'cpt-arc-dash', L_ARC_DOT = 'cpt-arc-dot'
const L_PROV = 'cpt-prov'
// ─── Map instance (shadow-DOM safe; reuses the proven @map composable) ────────
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken?.value || cfg.value.tk || '',
style: cfg.value.style ?? 'mapbox://styles/mapbox/light-v11',
center: cfg.value.center ?? [55, 22],
zoom: cfg.value.zoom ?? 1.7,
minZoom: cfg.value.minZoom ?? 1,
maxZoom: cfg.value.maxZoom ?? 12
})
// ─── religion → stable color (only used when colorBy === 'religion') ──────────
// Deterministic: distinct families of the focused senders, sorted, mapped to CAT.
const religionColorMap = computed(() => {
const fams = [...new Set(store.visibleSenders.value.map(s => s.religionFamily).filter(Boolean))].sort()
const m = {}
fams.forEach((f, i) => { m[f] = COLORS.CAT[i % COLORS.CAT.length] })
return m
})
// ─── geometry helpers ───────────────────────────────────────────────────────────
const fc = (features) => ({ type: 'FeatureCollection', features })
/** Great-circle polyline [lng,lat]→[lng,lat] (n segments) — navy SEND arcs curve. */
function greatCircle(a, b, n = 48) {
const R = Math.PI / 180, D = 180 / Math.PI
const lat1 = a[1] * R, lon1 = a[0] * R, lat2 = b[1] * R, lon2 = b[0] * R
const dLat = (lat2 - lat1) / 2, dLon = (lon2 - lon1) / 2
const h = Math.sin(dLat) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon) ** 2
const d = 2 * Math.asin(Math.min(1, Math.sqrt(h)))
if (!d) return [a, b]
const pts = []
for (let i = 0; i <= n; i++) {
const f = i / n
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2)
const y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
pts.push([Math.atan2(y, x) * D, Math.atan2(z, Math.sqrt(x * x + y * y)) * D])
}
return pts
}
// ─── feature builders (pure; read store derived state) ────────────────────────
function redFeatures() {
const feats = []
for (const c of store.clusters.value) {
if (!isFinite(c.lat) || !isFinite(c.lng)) continue
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [c.lng, c.lat] },
properties: {
id: c.id, count: c.count, hasSenders: !!c.hasSenders, senderCount: c.senderCount || 0,
color: redColor(c.count), r: Math.min(34, 6 + Math.sqrt(Math.max(0, c.count)) * 2.4),
label: c.country + ' (' + c.count + ')', country: c.country,
langs: (c.target_top_langs || []).slice(0, 3).join(', ')
}
})
}
return fc(feats)
}
function greenFeatures() {
const feats = []
const byReligion = store.colorBy.value === 'religion'
for (const n of store.mapNodes.value) {
if (!isFinite(n.lng) || !isFinite(n.lat)) continue
let color, r, pctTxt, popTxt, religion
if (n.isPool) {
const members = n.members || []
const mp = members.length ? members.reduce((s, x) => s + (x.pct || 0), 0) / members.length : 0
const pop = members.reduce((s, x) => s + (x.pop || 0), 0)
color = reachedColor(mp); r = logRadius(pop, 7, 26); pctTxt = ''; popTxt = fmtPop(pop); religion = ''
} else {
color = byReligion ? (religionColorMap.value[n.religionFamily] || COLORS.GREEN_BINS[2]) : (n.color || reachedColor(n.pct))
r = logRadius(n.pop); pctTxt = Math.round(n.pct) + '%'; popTxt = fmtPop(n.pop); religion = n.religionFamily || ''
}
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [n.lng, n.lat] },
properties: {
id: n.id, name: n.name, label: n.name, color, r, isPool: !!n.isPool,
country: n.country || '', lang: n.lang || '', pct: pctTxt, pop: popTxt, religion,
bridge: n.bridge || ''
}
})
}
return fc(feats)
}
function arcFeatures() {
const t = store.focusedTarget.value
if (!t || !isFinite(t.lat) || !isFinite(t.lng)) return fc([])
const center = [t.lng, t.lat]
const feats = []
for (const n of store.mapNodes.value) {
if (!isFinite(n.lng) || !isFinite(n.lat)) continue
const bridge = n.isPool ? 'same-country' : (n.bridge || 'same-country')
feats.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: greatCircle([n.lng, n.lat], center) },
properties: { bridge }
})
}
return fc(feats)
}
// ─── paint (idempotent; survives style reloads — the lang-cluster pattern) ────
function setSrc(m, id, data) {
const s = m.getSource(id)
if (s) s.setData(data); else m.addSource(id, { type: 'geojson', data })
}
function firstSymbolId(m) {
const layers = (m.getStyle && m.getStyle().layers) || []
const s = layers.find(l => l.type === 'symbol')
return s ? s.id : undefined
}
function ensureLayers(m) {
// SEND arcs (navy) — one layer per bridge LINE STYLE (line-dasharray is not
// data-driven in Mapbox, so the three bridge styles are three filtered layers).
if (!m.getLayer(L_ARC_SOLID)) m.addLayer({
id: L_ARC_SOLID, type: 'line', source: SRC_ARC,
filter: ['==', ['get', 'bridge'], 'same-language'],
layout: { 'line-cap': 'round' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 1.4, 'line-opacity': 0.6 }
})
if (!m.getLayer(L_ARC_DASH)) m.addLayer({
id: L_ARC_DASH, type: 'line', source: SRC_ARC,
filter: ['==', ['get', 'bridge'], 'related-cluster'],
layout: { 'line-cap': 'butt' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 1.4, 'line-opacity': 0.6, 'line-dasharray': [4, 3] }
})
if (!m.getLayer(L_ARC_DOT)) m.addLayer({
id: L_ARC_DOT, type: 'line', source: SRC_ARC,
filter: ['all', ['!=', ['get', 'bridge'], 'same-language'], ['!=', ['get', 'bridge'], 'related-cluster']],
layout: { 'line-cap': 'round' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 1.6, 'line-opacity': 0.6, 'line-dasharray': [1, 3] }
})
// GREEN reached senders (at REAL coords) + in-country pool
if (!m.getLayer(L_GREEN)) m.addLayer({
id: L_GREEN, type: 'circle', source: SRC_GREEN,
paint: {
'circle-color': ['get', 'color'], 'circle-radius': ['max', 2, ['*', ['get', 'r'], 0.55]],
'circle-stroke-width': ['case', ['get', 'isPool'], 2.5, 0.8],
'circle-stroke-color': ['case', ['get', 'isPool'], COLORS.GOLD, '#ffffff'],
'circle-opacity': 0.92, 'circle-opacity-transition': { duration: 350 }
}
})
if (!m.getLayer(L_GREEN_LBL)) m.addLayer({
id: L_GREEN_LBL, type: 'symbol', source: SRC_GREEN, minzoom: 3.4,
layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-offset': [0, 1.1],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
})
// RED unreached clusters (the default field) + collision-thinned labels
if (!m.getLayer(L_RED)) m.addLayer({
id: L_RED, type: 'circle', source: SRC_RED,
paint: {
'circle-color': ['get', 'color'], 'circle-radius': ['get', 'r'],
'circle-opacity': 0.82, 'circle-stroke-width': 2, 'circle-stroke-color': STROKE_GREEN,
'circle-opacity-transition': { duration: 300 }
}
})
if (!m.getLayer(L_RED_LBL)) m.addLayer({
id: L_RED_LBL, type: 'symbol', source: SRC_RED, minzoom: 2.4,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.2],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': COLORS.RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
})
}
/** Focus dim/selection: focused red bright, others dim to 0.5 (labels kept). */
function applySelection(m) {
if (!m.getLayer(L_RED)) return
const fid = store.focusedTargetId.value
m.setPaintProperty(L_RED, 'circle-opacity',
fid ? ['case', ['==', ['get', 'id'], fid], 0.95, 0.5]
: ['case', ['get', 'hasSenders'], 0.82, 0.14])
m.setPaintProperty(L_RED, 'circle-stroke-color',
fid ? ['case', ['==', ['get', 'id'], fid], COLORS.GOLD,
['case', ['get', 'hasSenders'], STROKE_GREEN, COLORS.RED_DK]]
: ['case', ['get', 'hasSenders'], STROKE_GREEN, COLORS.RED_DK])
m.setPaintProperty(L_RED, 'circle-stroke-width',
fid ? ['case', ['==', ['get', 'id'], fid], 4, ['case', ['get', 'hasSenders'], 2.4, 1.6]]
: ['case', ['get', 'hasSenders'], 2.4, 1.6])
}
/** PROVINCE drill — overlay admin-1 boundaries (IN/CN sub-division). Best-effort:
* uses the base style's vector 'admin' layer (no province polygons ship on disk,
* so this is a boundary overlay, not a data-keyed choropleth). */
function setProvince(m, on) {
if (on) {
if (!m.getLayer(L_PROV)) {
try {
const before = m.getLayer(L_ARC_SOLID) ? L_ARC_SOLID : firstSymbolId(m)
m.addLayer({
id: L_PROV, type: 'line', source: 'composite', 'source-layer': 'admin',
filter: ['all', ['==', ['get', 'admin_level'], 1], ['==', ['get', 'maritime'], 'false']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 0.7, 'line-opacity': 0.45, 'line-dasharray': [2, 2] }
}, before)
} catch (_) { /* base source lacks an admin layer — silent */ }
} else { try { m.setLayoutProperty(L_PROV, 'visibility', 'visible') } catch (_) {} }
} else if (m.getLayer(L_PROV)) {
try { m.setLayoutProperty(L_PROV, 'visibility', 'none') } catch (_) {}
}
}
function paint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
setSrc(m, SRC_ARC, arcFeatures())
setSrc(m, SRC_GREEN, greenFeatures())
setSrc(m, SRC_RED, redFeatures())
ensureLayers(m)
applySelection(m)
setHoverHighlight(_hoverId)
setProvince(m, store.provinceDrill.value)
if (store.focusedTargetId.value) startMarch(); else stopMarch()
}
// ─── camera framing ─────────────────────────────────────────────────────────────
function frame() {
const m = map.value
if (!m) return
const t = store.focusedTarget.value
if (!t) {
try { m.stop(); m.easeTo({ center: cfg.value.center ?? [55, 22], zoom: cfg.value.zoom ?? 1.7, duration: 700 }) } catch (_) {}
return
}
const pts = []
if (isFinite(t.lat) && isFinite(t.lng)) pts.push([t.lng, t.lat])
for (const n of store.mapNodes.value) if (isFinite(n.lng) && isFinite(n.lat)) pts.push([n.lng, n.lat])
if (!pts.length) return
let minLng = pts[0][0], maxLng = pts[0][0], minLat = pts[0][1], maxLat = pts[0][1]
for (const p of pts) {
minLng = Math.min(minLng, p[0]); maxLng = Math.max(maxLng, p[0])
minLat = Math.min(minLat, p[1]); maxLat = Math.max(maxLat, p[1])
}
try {
m.stop()
if (minLng === maxLng && minLat === maxLat) m.easeTo({ center: [minLng, minLat], zoom: 4.5, duration: 800 })
else m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 80, maxZoom: 6, duration: 800 })
} catch (_) {}
}
// ─── marching-ant dash animation on the SEND arcs (dashed + dotted layers) ────
const DASH_SEQ = [
[0, 4, 3], [0.5, 4, 2.5], [1, 4, 2], [1.5, 4, 1.5], [2, 4, 1], [2.5, 4, 0.5],
[3, 4, 0], [0, 0.5, 4, 2.5], [0, 1, 4, 2], [0, 1.5, 4, 1.5], [0, 2, 4, 1], [0, 2.5, 4, 0.5]
]
const DOT_SEQ = [[1, 3], [0, 0.75, 1, 2.25], [0, 1.5, 1, 1.5], [0, 2.25, 1, 0.75]]
let dashTimer = null, dashIx = 0
function startMarch() {
if (dashTimer) return
dashTimer = setInterval(() => {
const m = map.value
if (!m) return
dashIx++
try {
if (m.getLayer(L_ARC_DASH)) m.setPaintProperty(L_ARC_DASH, 'line-dasharray', DASH_SEQ[dashIx % DASH_SEQ.length])
if (m.getLayer(L_ARC_DOT)) m.setPaintProperty(L_ARC_DOT, 'line-dasharray', DOT_SEQ[dashIx % DOT_SEQ.length])
} catch (_) {}
}, 80)
}
function stopMarch() { if (dashTimer) { clearInterval(dashTimer); dashTimer = null } }
// ─── two-way hover link (map ↔ list/flower) ──────────────────────────────────
// The store does not (yet) expose a shared hover field; we drive a LOCAL highlight
// for immediate map feedback and also call store.setHover(id) / react to
// store.hoveredId IF the foundation later adds them (both calls are no-op-safe).
let _hoverId = null
function setHoverHighlight(id) {
_hoverId = id
const m = map.value
if (!m || !m.getLayer(L_GREEN)) return
m.setPaintProperty(L_GREEN, 'circle-stroke-width',
id ? ['case', ['==', ['get', 'id'], id], 4, ['case', ['get', 'isPool'], 2.5, 1.2]]
: ['case', ['get', 'isPool'], 2.5, 1.2])
m.setPaintProperty(L_GREEN, 'circle-stroke-color',
id ? ['case', ['==', ['get', 'id'], id], COLORS.GOLD, ['case', ['get', 'isPool'], COLORS.GOLD, '#ffffff']]
: ['case', ['get', 'isPool'], COLORS.GOLD, '#ffffff'])
}
// ─── tooltips (hover-pin; HTML built by concatenation — no nested backticks) ──
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
function onMove(ev) { if (tip.value.show && !tip.value.pinned) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
function redTipHtml(p) {
return '<strong>' + esc(p.country) + '</strong>'
+ '<div class="cpt-mc-tsub">' + esc(p.count) + ' unreached groups</div>'
+ (p.langs ? '<div class="cpt-mc-trow"><span class="cpt-mc-tk">langs</span> ' + esc(p.langs) + '</div>' : '')
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">senders</span> '
+ (p.hasSenders ? esc(p.senderCount) + ' reached nearby' : 'frontier — 0 (pray for laborers)') + '</div>'
+ '<div class="cpt-mc-trow" style="color:#9fb3d9">click → SEND view</div>'
}
function greenTipHtml(p) {
if (p.isPool) return '<strong>' + esc(p.name) + '</strong>'
+ '<div class="cpt-mc-tsub">same-country reached pool</div>'
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">pop</span> ' + esc(p.pop) + '</div>'
+ '<div class="cpt-mc-trow" style="color:#9fb3d9">spiderfies in the list</div>'
return '<strong>' + esc(p.name) + '</strong>'
+ '<div class="cpt-mc-tsub">' + esc(p.country) + ' · ' + esc(p.lang) + '</div>'
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">reached</span> ' + esc(p.pct) + ' Christian</div>'
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">pop</span> ' + esc(p.pop) + '</div>'
+ (p.bridge ? '<div class="cpt-mc-trow"><span class="cpt-mc-tk">bridge</span> ' + esc(p.bridge) + '</div>' : '')
+ (p.religion ? '<div class="cpt-mc-trow"><span class="cpt-mc-tk">religion</span> ' + esc(p.religion) + '</div>' : '')
+ '<div class="cpt-mc-trow" style="color:#9fb3d9">click → pin · highlights the list</div>'
}
// ─── boot ──────────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
const m = map.value
if (!m) { return } // no/invalid token — overlay shows the hint, store still drives the rest
try { m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
// Gated test hook (inert in production): lets a harness use the live map.project().
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try { (mcRoot.value?.getRootNode?.()?.host || window).__cockpitmap = m } catch (_) {}
}
const hover = (layer, html) => {
m.on('mouseenter', layer, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', layer, () => { m.getCanvas().style.cursor = ''; if (!tip.value.pinned) tip.value = { ...tip.value, show: false } })
m.on('mousemove', layer, (e) => {
const f = e.features?.[0]; if (!f) return
tip.value = { show: true, x: e.originalEvent.clientX, y: e.originalEvent.clientY, html: html(f.properties) }
})
}
const wire = () => {
paint()
// click a red bubble → select its target (the cockpit's primary interaction)
m.on('click', L_RED, (e) => { const f = e.features?.[0]; if (f) { tip.value = { ...tip.value, show: false, pinned: false }; store.selectTarget(f.properties.id) } })
hover(L_RED, redTipHtml)
hover(L_GREEN, greenTipHtml)
// green hover → local highlight + (no-op-safe) store hover for the two-way link
m.on('mousemove', L_GREEN, (e) => { const f = e.features?.[0]; if (f) { setHoverHighlight(f.properties.id); try { store.setHover && store.setHover(f.properties.id) } catch (_) {} } })
m.on('mouseleave', L_GREEN, () => { setHoverHighlight(null); try { store.setHover && store.setHover(null) } catch (_) {} })
// click a green node → fly to it (pool spiderfies via the list)
// click a green reached pin → PIN its neighbor profile (stays open) + highlight/scroll
// its list row (two-way) + fly to it.
m.on('click', L_GREEN, (e) => {
const f = e.features?.[0]; if (!f) return
const ev = e.originalEvent || {}
tip.value = { show: true, pinned: true, x: ev.clientX || tip.value.x, y: ev.clientY || tip.value.y, html: greenTipHtml(f.properties) }
try { store.setHover && store.setHover(f.properties.id) } catch (_) {}
try { m.stop(); m.easeTo({ center: f.geometry.coordinates, zoom: Math.max(m.getZoom(), 4.5), duration: 700 }) } catch (_) {}
})
ready.value = true
if (store.focusedTargetId.value) frame()
}
if (m.isStyleLoaded?.()) wire(); else m.once?.('load', wire)
// belt-and-suspenders repaint (style timing race — the lang-cluster lesson)
m.once?.('idle', () => { if (ready.value) paint() })
})
// ─── react to store state ───────────────────────────────────────────────────────
watch(() => [store.ready.value, store.clusters.value, store.mapNodes.value, store.colorBy.value, store.provinceDrill.value],
() => paint())
watch(() => store.focusedTargetId.value, () => { paint(); frame() })
watch(() => store.mode.value, () => { nextTick(() => { try { map.value?.resize() } catch (_) {} }) })
// optional incoming half of the two-way hover link (if the store later adds it)
if (store.hoveredId) watch(() => store.hoveredId.value, (id) => setHoverHighlight(id))
onBeforeUnmount(() => { stopMarch(); destroy() })
// ─── Shadow-DOM CSS (single template; palette interpolated; no nested backticks) ─
useShadowStyles(`
.cpt-mc-root { position:absolute; inset:0; overflow:hidden; background:#eef2f7; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.cpt-mc-map { position:absolute; inset:0; }
.cpt-mc-loading { position:absolute; inset:0; z-index:5; display:flex; align-items:center; justify-content:center;
background:#eef2f7; color:${COLORS.NAVY}; font:600 12px/1.5 ui-monospace,monospace; text-align:center; padding:18px; }
/* on-map control cluster (top-left) — color-by + province drill + reset */
.cpt-mc-ctl { position:absolute; top:8px; left:8px; z-index:30; display:flex; flex-direction:column; gap:6px; align-items:flex-start; }
.cpt-mc-seg { display:inline-flex; background:rgba(255,255,255,.95); border:1px solid #cbd5e1; border-radius:999px; padding:2px; gap:2px; box-shadow:0 2px 8px rgba(0,0,0,.14); }
.cpt-mc-seg button { border:0; background:transparent; color:#475569; cursor:pointer; font:700 10px/1 ui-monospace,monospace; padding:5px 9px; border-radius:999px; }
.cpt-mc-seg button.on { background:${COLORS.NAVY}; color:#fff; }
.cpt-mc-btn { border:1px solid #cbd5e1; background:rgba(255,255,255,.95); color:${COLORS.NAVY}; cursor:pointer;
font:700 10px/1 ui-monospace,monospace; padding:6px 10px; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.14); }
.cpt-mc-btn.on { background:${COLORS.NAVY}; color:#fff; border-color:${COLORS.NAVY}; }
.cpt-mc-btn.gold { background:${COLORS.GOLD}; color:#3a2600; border-color:${COLORS.GOLD}; }
/* SEND headline (bottom-left) when a target is focused */
.cpt-mc-headline { position:absolute; left:8px; bottom:8px; right:8px; z-index:28; pointer-events:none;
background:rgba(0,46,110,.92); color:#fff; border-radius:9px; padding:7px 11px; max-width:560px;
font:700 12px/1.35 -apple-system,sans-serif; box-shadow:0 4px 14px rgba(0,0,0,.28); }
/* hover-pin tooltip */
.cpt-mc-tip { position:fixed; z-index:60; pointer-events:none; max-width:250px; background:#0e1730;
border:1px solid #34508c; border-radius:9px; padding:9px 11px; box-shadow:0 6px 22px rgba(0,0,0,.5); font-size:11.5px; color:#dbe4f3; }
.cpt-mc-tip strong { font-size:13px; color:#fff; }
.cpt-mc-tsub { color:#fda4af; font:600 10px/1.3 ui-monospace,monospace; margin:2px 0 6px; }
.cpt-mc-trow { display:flex; gap:6px; line-height:1.5; }
.cpt-mc-tk { color:#7e90b6; min-width:62px; }
@media (max-width:560px){ .cpt-mc-headline{ font-size:11px; } }
`, 'cockpit-mapcell')
</script>
<template>
<div ref="mcRoot" class="cpt-mc-root" @mousemove="onMove">
<div ref="mapContainer" class="cpt-mc-map"></div>
<!-- on-map controls: exposes color-by (8) + province drill (9) + reset focus -->
<div v-if="ready" class="cpt-mc-ctl">
<div class="cpt-mc-seg" role="group" aria-label="Green color encoding">
<button :class="{ on: store.colorBy.value === 'pct' }" @click="store.setColorBy('pct')">% reached</button>
<button :class="{ on: store.colorBy.value === 'religion' }" @click="store.setColorBy('religion')">religion</button>
</div>
<button class="cpt-mc-btn" :class="{ on: store.provinceDrill.value }" @click="store.toggleProvince()"
title="Overlay IN/CN admin-1 boundaries">IN/CN provinces</button>
<button v-if="store.focusedTarget.value" class="cpt-mc-btn gold" @click="store.clearFocus()">✕ reset focus</button>
</div>
<!-- SEND headline when focused -->
<div v-if="ready && store.focusedTarget.value" class="cpt-mc-headline">{{ store.headline.value }}</div>
<!-- token/load hint (map never initialized — store still drives the other cells) -->
<div v-if="!ready" class="cpt-mc-loading">
<span v-if="tokenMissing">Map needs a Mapbox token (tk in profile-config)…</span>
<span v-else>Rendering the harvest field…</span>
</div>
<!-- hover-pin tooltip -->
<div v-if="tip.show" class="cpt-mc-tip" :style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }" v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,568 @@
/**
* useCockpit.js — the SHARED crossfilter store for the "BloomField Cockpit".
*
* ONE reactive store is the single source of truth binding the four cockpit cells
* (MAP + FLOWER + LIST + CHART STRIP). Every cell INJECTs the same instance, so a
* facet click in the chart strip, a red tap on the map, a search in the header and a
* sort in the list all breathe together (cockpit / crossfilter pattern).
*
* 0-DEPENDENCY: imports ONLY from 'vue'. The baked data (senders + admin1) is
* STATIC-imported so it ships INSIDE the bundle (a runtime ./data fetch 404s in the
* real platform gem-frame — the PPLR-1 lesson). The live unreached "reds" are fetched
* from pray.doxa.life in loadData().
*
* USAGE
* // shell (cockpit.vue):
* import { createCockpitStore } from './cockpit/useCockpit.js'
* const store = createCockpitStore()
* provide('cockpitStore', store)
* onMounted(() => store.loadData(profileConfig?.value))
*
* // any cell:
* import { useCockpitStore } from './useCockpit.js'
* const store = useCockpitStore()
* // store.clusters.value, store.visibleSenders.value, store.selectTarget(id), …
*
* Every store member that is a ref/computed is consumed with `.value`. Actions are
* plain functions. Deterministic — NO Date.now()/Math.random() inside any computed
* (the one timestamp, loadedAt, is captured in the loadData ACTION).
*
* Full field-by-field contract: ./STORE-API.md
*/
import { ref, computed, inject } from 'vue'
// ─────────────────────────────────────────────────────────────────────────────
// LOCKED COLOR CONTRACT (shared by all four cells — import from here, never re-hardcode)
// ─────────────────────────────────────────────────────────────────────────────
export const COLORS = {
RED: '#e11d48', // unreached target (base)
RED_DK: '#9f1239',
NAVY: '#002e6e', // SEND arcs / chrome
GOLD: '#f59e0b', // selection highlight
WHITE: '#ffffff',
// GREEN = 5 QUANTILE bins on pct_christian (2-10 / 10-25 / 25-50 / 50-75 / 75-100)
GREEN_BINS: ['#9bcf9b', '#5cb874', '#1f9d55', '#0f7a3d', '#0a5c2e'],
// RED orange-red ramp — deeper = MORE unreached (target_upg buckets)
RED_RAMP: ['#fdba74', '#fb923c', '#f97316', '#ea580c', '#c2410c'],
// donut categorical (used when a chart must color bridge/religion categories)
CAT: ['#002e6e', '#1f9d55', '#f59e0b', '#e11d48', '#7c3aed', '#0891b2', '#65a30d', '#be123c', '#a16207']
}
export const GREEN_BIN_LABELS = ['2–10%', '10–25%', '25–50%', '50–75%', '75–100%']
// ─────────────────────────────────────────────────────────────────────────────
// PURE HELPERS (exported so every cell bins/colors/sizes IDENTICALLY)
// ─────────────────────────────────────────────────────────────────────────────
/** 5-bin quantile index for a %Christian value → 0..4 (matches GREEN_BINS / GREEN_BIN_LABELS). */
export function quantileBin(pct) {
const p = +pct || 0
if (p < 10) return 0
if (p < 25) return 1
if (p < 50) return 2
if (p < 75) return 3
return 4
}
/** Green color for a reached sender's %Christian (locked quantile ramp). */
export function reachedColor(pct) { return COLORS.GREEN_BINS[quantileBin(pct)] }
/** Orange-red color for a red cluster by its unreached count (deeper = more unreached). */
export function redColor(count) {
const c = +count || 0
if (c >= 80) return COLORS.RED_RAMP[4]
if (c >= 40) return COLORS.RED_RAMP[3]
if (c >= 15) return COLORS.RED_RAMP[2]
if (c >= 5) return COLORS.RED_RAMP[1]
return COLORS.RED_RAMP[0]
}
/** LOG-scaled radius for a population (px). 30 → ~4px, 901M → ~22px. Shared map+flower. */
export function logRadius(pop, min = 4, max = 22) {
const p = Math.max(1, +pop || 0)
const t = Math.min(1, Math.log10(p) / 9) // log10(1e9) = 9 → full scale at ~1B
return min + (max - min) * t
}
/** Bridge type → LINE STYLE (color is NEVER used for bridge — line style only). */
export function bridgeDash(bridge) {
if (bridge === 'same-language') return null // solid
if (bridge === 'related-cluster') return [4, 3] // dashed
return [1, 3] // dotted (same-country / other)
}
/** Religion background → coarse family (text before " - "). */
export function religionFamily(s) {
const v = String(s || '').trim()
if (!v) return 'Unknown'
return v.split(' - ')[0].trim() || 'Unknown'
}
/** Human population formatter (30 / 12K / 2.1M / 1.3B). */
export function fmtPop(n) {
const v = +n || 0
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
if (v >= 1e3) return Math.round(v / 1e3) + 'K'
return String(Math.round(v))
}
const norm = (s) => String(s || '').trim().toLowerCase()
const lbl = (f) => (f && typeof f === 'object') ? (f.label ?? f.value ?? '') : (f ?? '')
const val = (f) => (f && typeof f === 'object') ? (f.value ?? f.label ?? '') : (f ?? '')
// Live doxa country labels → baked-JSON country strings (so reds line up with senders).
const COUNTRY_DISPLAY = {
"People's Republic of China": 'China',
'Lao People’s Democratic Republic': 'Laos',
"Lao People's Democratic Republic": 'Laos'
}
const normCountry = (s) => COUNTRY_DISPLAY[s] || s
// ─────────────────────────────────────────────────────────────────────────────
// STATIC baked data (ships inside the bundle — no runtime ./data fetch)
// ─────────────────────────────────────────────────────────────────────────────
import bakedSenders from '../data/reached-neighbors.json'
import bakedAdmin1 from '../data/admin1-membership.json'
// ─────────────────────────────────────────────────────────────────────────────
// THE FACTORY
// ─────────────────────────────────────────────────────────────────────────────
export function createCockpitStore() {
// ── primitive state ───────────────────────────────────────────────────────
const focusedTargetId = ref(null) // cluster id (== target country) | null
const facets = ref({
languages: new Set(), // sender/target language labels
countries: new Set(), // sender/target country labels
religions: new Set(), // religion family labels
bridges: new Set(), // bridge type values
reachedMin: null // number | null (pct_christian floor)
})
const search = ref('')
const colorBy = ref('pct') // 'pct' | 'religion'
const languageLens = ref(null) // string | null (a bridge language)
const provinceDrill = ref(false) // IN/CN admin1 choropleth toggle
const mode = ref('map') // 'map' | 'flower' (the morph)
const sortKey = ref('mobilization') // see listRows
// shared HOVER bus for the two-way map ↔ list ↔ flower link (interaction #3):
// a sender id (or '__pool__') that is currently hovered in ANY cell. Each cell
// writes it via setHover(id) and reacts to it for a synchronized pulse/scroll.
const hoveredId = ref(null) // Sender.id | '__pool__' | null
// ── load lifecycle ────────────────────────────────────────────────────────
const ready = ref(false)
const loadError = ref('')
const loadedAt = ref('') // ISO string captured in the ACTION
// raw caches (filled by loadData)
const allClusters = ref([]) // [Cluster] (red, unfiltered)
const allSenders = ref([]) // [Sender] (flat, unfiltered)
const admin1 = ref(bakedAdmin1 || {}) // { pg-slug : "Province" }
const meta = ref(bakedSenders?.meta || {})
// ── DATA LOAD (async action) ──────────────────────────────────────────────
async function loadData(profileConfig) {
const cfg = profileConfig || {}
loadError.value = ''
// 1) baked senders → flat list (real lat/lng on every row)
allSenders.value = buildSenders(bakedSenders)
// index senderCount + meta per target country
const counts = senderCountsByCountry(allSenders.value)
// 2) live unreached reds → clusters grouped by country
let reds = []
try { reds = await fetchReds(cfg) }
catch (e) {
loadError.value = 'Live unreached API unreachable — ' + (e?.message || e)
// still usable: build clusters from the baked target meta so the cockpit renders
allClusters.value = clustersFromBaked(bakedSenders, counts)
loadedAt.value = new Date().toISOString()
ready.value = true
return
}
allClusters.value = buildClusters(reds, counts)
loadedAt.value = new Date().toISOString()
ready.value = true
}
// ── DERIVED: clusters after facets + search + language lens ────────────────
const clusters = computed(() => {
let list = allClusters.value
const f = facets.value
if (f.countries.size) list = list.filter(c => f.countries.has(c.country))
if (f.languages.size) list = list.filter(c => c.langs.some(l => f.languages.has(l)))
const q = norm(search.value)
if (q) list = list.filter(c => norm(c.label).includes(q) || norm(c.country).includes(q) ||
c.langs.some(l => norm(l).includes(q)))
if (languageLens.value) {
const lens = norm(languageLens.value)
const reachable = sendersForLens(lens)
list = list.filter(c => reachable.has(c.id))
}
// default ordering = mobilization readiness (senderCount desc × unreached pop desc)
return [...list].sort((a, b) =>
(b.senderCount - a.senderCount) || (b.target_pop - a.target_pop) || (b.count - a.count))
})
const focusedTarget = computed(() =>
focusedTargetId.value ? allClusters.value.find(c => c.id === focusedTargetId.value) || null : null)
// senders for the focused target, BEFORE facets (for the honest "hidden" count)
const sendersForFocus = computed(() =>
focusedTargetId.value ? allSenders.value.filter(s => s.targetId === focusedTargetId.value) : [])
// ── DERIVED: visible senders (focused target, matching facets + search) ────
const visibleSenders = computed(() => {
let list = sendersForFocus.value
const f = facets.value
if (f.languages.size) list = list.filter(s => f.languages.has(s.lang))
if (f.countries.size) list = list.filter(s => f.countries.has(s.country))
if (f.religions.size) list = list.filter(s => f.religions.has(s.religionFamily))
if (f.bridges.size) list = list.filter(s => f.bridges.has(s.bridge))
if (f.reachedMin != null) list = list.filter(s => s.pct >= f.reachedMin)
const q = norm(search.value)
if (q) list = list.filter(s => norm(s.name).includes(q) || norm(s.lang).includes(q) ||
norm(s.country).includes(q))
return list
})
// ── DERIVED: map nodes — GRANULAR per-PG pins (UPGN-MERGE / Driver) ─────────
// Every reached people-group is its OWN pin at its ACTUAL per-PG lat/lng from
// PPLR (the data carries real Lat/Long for all senders — incl. same-country,
// which are NOT co-located: e.g. India's 150 senders spread across real states).
// No more in-country pool node, no centroids — the "granular actual people-group
// xy coordinates from PPLR for the actual reached people" the Driver asked for.
// (The earlier pool existed only because the OLD data smeared same-country senders
// onto one centroid; the PPLR-2 data fixed that, so granular is now correct.)
const mapNodes = computed(() =>
visibleSenders.value
.filter(s => isFinite(s.lat) && isFinite(s.lng))
.map(s => ({ ...s, isPool: false })))
// ── DERIVED: honest counters ───────────────────────────────────────────────
const counts = computed(() => {
const cl = clusters.value.length
if (focusedTargetId.value) {
const total = sendersForFocus.value.length
const vis = visibleSenders.value.length
return { senders: vis, clusters: cl, hidden: Math.max(0, total - vis) }
}
const senders = clusters.value.reduce((s, c) => s + c.senderCount, 0)
return { senders, clusters: cl, hidden: 0 }
})
// ── DERIVED: headline string ───────────────────────────────────────────────
const headline = computed(() => {
const c = focusedTarget.value
if (!c) {
return counts.value.clusters + ' unreached clusters · ' +
counts.value.senders.toLocaleString() + ' reached senders nearby — pick a red to send'
}
const vis = visibleSenders.value
const lensWord = languageLens.value || [...facets.value.languages][0] || ''
const top = [...vis].sort((a, b) => (b.pct - a.pct) || (b.pop - a.pop))[0]
const pre = (lensWord ? lensWord + ' × ' : '') + c.country
const topStr = top ? ' · top ' + top.name + ' ' + Math.round(top.pct) + '%, ' + fmtPop(top.pop) : ''
return pre + ': ' + c.count + ' unreached · ' + vis.length +
(lensWord ? ' nearby ' + lensWord + ' senders' : ' nearby senders') + topStr
})
// ── DERIVED: list rows (sorted senders of the focused target) ──────────────
const listRows = computed(() => {
const rows = [...visibleSenders.value]
const bridgeRank = { 'same-language': 0, 'related-cluster': 1, 'same-country': 2 }
switch (sortKey.value) {
case 'pct': rows.sort((a, b) => b.pct - a.pct || b.pop - a.pop); break
case 'pop': rows.sort((a, b) => b.pop - a.pop || b.pct - a.pct); break
case 'name': rows.sort((a, b) => a.name.localeCompare(b.name)); break
case 'bridge': rows.sort((a, b) =>
(bridgeRank[a.bridge] ?? 9) - (bridgeRank[b.bridge] ?? 9) || b.pct - a.pct); break
case 'frontier': // fewest-senders × largest-pop → big reached pops, weakest first
rows.sort((a, b) => b.pop - a.pop || a.pct - b.pct); break
case 'mobilization': // DEFAULT: readiness = strongest × largest
default: rows.sort((a, b) => b.pct - a.pct || b.pop - a.pop); break
}
return rows
})
// ── DERIVED: chart data (4 zero-dep SVG charts; computed from visibleSenders) ─
const chartData = computed(() => {
const base = focusedTargetId.value ? visibleSenders.value
: allSenders.value.filter(s => clusterIds().has(s.targetId))
return {
languageBar: topBars(base, s => s.lang, 12),
countryBar: topBars(base, s => s.country, 12),
reachedHist: GREEN_BIN_LABELS.map((label, i) => ({
key: i, label, value: base.filter(s => s.bin === i).length, color: COLORS.GREEN_BINS[i]
})),
donut: colorBy.value === 'religion'
? donutBy(base, s => s.religionFamily)
: donutBy(base, s => s.bridge)
}
})
// ── DERIVED: provenance (for the header pill — honesty contract) ───────────
const provenance = computed(() => ({
liveTargets: allClusters.value.length,
liveUnreached: allClusters.value.reduce((s, c) => s + c.count, 0),
loadedAt: loadedAt.value,
bakedGenerated: meta.value?.generated || '',
bakedSenders: meta.value?.total_neighbors || allSenders.value.length,
reachedFilter: meta.value?.reached_filter || '',
admin1Level: 'IN/CN admin1 (point-in-polygon, ' + Object.keys(admin1.value).length + ' groups)',
apiError: loadError.value
}))
// ── DERIVED: active filter chips (header breadcrumb; each removable) ────────
const chips = computed(() => {
const out = []
const f = facets.value
if (focusedTargetId.value && focusedTarget.value)
out.push({ kind: 'focus', value: focusedTarget.value.country, label: focusedTarget.value.country })
for (const v of f.languages) out.push({ kind: 'languages', value: v, label: v })
for (const v of f.countries) out.push({ kind: 'countries', value: v, label: v })
for (const v of f.religions) out.push({ kind: 'religions', value: v, label: v })
for (const v of f.bridges) out.push({ kind: 'bridges', value: v, label: v })
if (f.reachedMin != null) out.push({ kind: 'reachedMin', value: f.reachedMin, label: 'reached% ≥ ' + f.reachedMin })
if (languageLens.value) out.push({ kind: 'lens', value: languageLens.value, label: 'lens: ' + languageLens.value })
return out
})
// ───────────────────────────────────────────────────────────────────────────
// ACTIONS
// ───────────────────────────────────────────────────────────────────────────
function selectTarget(id) { focusedTargetId.value = id }
function clearFocus() { focusedTargetId.value = null }
function toggleFacet(kind, value) {
const set = facets.value[kind]
if (!(set instanceof Set)) return
if (set.has(value)) set.delete(value); else set.add(value)
facets.value = { ...facets.value } // trigger reactivity (Set mutation)
}
function setReachedMin(n) {
facets.value = { ...facets.value, reachedMin: (n == null || n === '' ? null : +n) }
}
function setSearch(s) { search.value = String(s ?? '') }
function setColorBy(v) { colorBy.value = (v === 'religion') ? 'religion' : 'pct' }
function setLanguageLens(l) { languageLens.value = l || null }
function toggleProvince() { provinceDrill.value = !provinceDrill.value }
function setMode(m) { mode.value = (m === 'flower') ? 'flower' : 'map' }
function setSort(key) { sortKey.value = key || 'mobilization' }
/** Shared hover bus — set/clear the currently-hovered sender id (two-way link). */
function setHover(id) { hoveredId.value = id || null }
/** Remove a single chip (from the header breadcrumb). */
function removeChip(chip) {
if (!chip) return
if (chip.kind === 'focus') return clearFocus()
if (chip.kind === 'reachedMin') return setReachedMin(null)
if (chip.kind === 'lens') return setLanguageLens(null)
return toggleFacet(chip.kind, chip.value)
}
function clearAllFacets() {
facets.value = { languages: new Set(), countries: new Set(), religions: new Set(),
bridges: new Set(), reachedMin: null }
languageLens.value = null
search.value = ''
}
/**
* Export the given rows (defaults to listRows) to a CSV download via a native Blob.
* CSV-injection-safe: any cell beginning with = + - @ is prefixed with a single quote,
* and every field is double-quote escaped. First line = a provenance comment.
*/
function exportSelectionCSV(rows) {
const data = Array.isArray(rows) ? rows : listRows.value
const cols = ['name', 'country', 'lang', 'rol3', 'pct', 'pop', 'bridge', 'adjacency', 'religion_bg']
const safe = (v) => {
let s = String(v ?? '')
if (/^[=+\-@]/.test(s)) s = "'" + s // neutralize formula injection
return '"' + s.replace(/"/g, '""') + '"'
}
const prov = '# BloomField Cockpit — reached neighbors for ' +
(focusedTarget.value?.country || 'all targets') +
' · baked ' + (meta.value?.generated || '') +
' · ' + data.length + ' rows · ' + (loadedAt.value || '')
const header = cols.join(',')
const body = data.map(r => cols.map(c => safe(r[c])).join(',')).join('\n')
const csv = prov + '\n' + header + '\n' + body + '\n'
try {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'send-list-' + (focusedTarget.value?.country || 'all').replace(/\W+/g, '-').toLowerCase() + '.csv'
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (_) { /* download blocked (sandbox) — silent */ }
return csv
}
// ── internal helpers (closure over caches) ─────────────────────────────────
function clusterIds() { return new Set(clusters.value.map(c => c.id)) }
// map: target country -> Set of cluster ids that have a sender of the lens language
function sendersForLens(lensLk) {
const ids = new Set()
for (const s of allSenders.value) if (norm(s.lang) === lensLk) ids.add(s.targetId)
return ids
}
// expose
return {
// primitive state
focusedTargetId, facets, search, colorBy, languageLens, provinceDrill, mode, sortKey,
// shared hover bus (exposed under BOTH names the cells consume — same ref)
hoveredId, hoveredSenderId: hoveredId,
// lifecycle
ready, loadError, loadedAt, meta, admin1,
// raw caches (read-only by convention)
allClusters, allSenders,
// derived
clusters, focusedTarget, sendersForFocus, visibleSenders, mapNodes,
counts, headline, listRows, chartData, provenance, chips,
// actions
loadData, selectTarget, clearFocus, toggleFacet, setReachedMin, setSearch,
setColorBy, setLanguageLens, toggleProvince, setMode, setSort, setHover, removeChip,
clearAllFacets, exportSelectionCSV,
// helpers re-exported on the instance for convenience
quantileBin, reachedColor, redColor, logRadius, bridgeDash, religionFamily, fmtPop, COLORS
}
}
/** Inject the shared store provided by cockpit.vue. */
export function useCockpitStore() {
const store = inject('cockpitStore', null)
if (!store) throw new Error('[useCockpitStore] no cockpitStore provided — mount inside <cockpit>')
return store
}
// ─────────────────────────────────────────────────────────────────────────────
// BUILDERS (pure module functions — no Vue, fully testable)
// ─────────────────────────────────────────────────────────────────────────────
/** Flatten reached-neighbors.json into [Sender] with targetId + quantile bin + religion family. */
function buildSenders(json) {
const out = []
const countries = json?.countries || {}
for (const [targetId, t] of Object.entries(countries)) {
for (const n of (t.neighbors || [])) {
const lat = Number(n.lat), lng = Number(n.lng)
if (!isFinite(lat) || !isFinite(lng)) continue
const pct = +n.pct_christian || 0
out.push({
id: n.code || (targetId + '|' + n.name + '|' + n.country + '|' + (n.rol3 || '')),
targetId,
name: n.name, country: n.country, lang: n.lang, rol3: n.rol3 || '',
pct, pop: +n.pop || 0,
bridge: n.bridge || '', adjacency: n.adjacency || '',
religion_bg: n.religion_bg || '', religionFamily: religionFamily(n.religion_bg),
lat, lng, code: n.code || '',
bin: quantileBin(pct), color: reachedColor(pct)
})
}
}
return out
}
/** target country -> { count, pop } sender aggregate. */
function senderCountsByCountry(senders) {
const m = new Map()
for (const s of senders) {
let e = m.get(s.targetId)
if (!e) { e = { count: 0, pop: 0 }; m.set(s.targetId, e) }
e.count++; e.pop += s.pop
}
return m
}
/** Build red clusters from LIVE doxa people-groups, grouped by country. */
function buildClusters(reds, senderCounts) {
const m = new Map()
for (const r of reds) {
const lat = Number(r.latitude), lng = Number(r.longitude)
const country = normCountry(lbl(r.country_code) || '(unknown)')
const lang = lbl(r.primary_language) || ''
const slug = r.slug || val(r.primary_language) + ':' + r.name
let c = m.get(country)
if (!c) {
c = { id: country, country, label: country, count: 0, target_pop: 0,
_sumLat: 0, _sumLng: 0, _n: 0, langSet: new Set(), members: [] }
m.set(country, c)
}
c.count++
if (lang) c.langSet.add(lang)
c.members.push({ name: r.name, slug, lang, lat, lng })
if (isFinite(lat) && isFinite(lng)) { c._sumLat += lat; c._sumLng += lng; c._n++ }
}
return finalizeClusters([...m.values()], senderCounts)
}
/** Fallback: build clusters straight from the baked target meta (live API down). */
function clustersFromBaked(json, senderCounts) {
const out = []
for (const [country, t] of Object.entries(json?.countries || {})) {
const sc = senderCounts.get(country)
out.push({
id: country, country, label: country,
count: +t.target_upg || 0, target_pop: +t.target_pop || 0,
lat: null, lng: null, langs: t.target_top_langs || [],
target_top_langs: t.target_top_langs || [], target_top_religion: t.target_top_religion || '',
senderCount: sc ? sc.count : 0, hasSenders: !!sc, color: redColor(+t.target_upg || 0)
})
}
return out
}
function finalizeClusters(list, senderCounts) {
const out = []
for (const c of list) {
if (c._n) { c.lat = c._sumLat / c._n; c.lng = c._sumLng / c._n }
else { c.lat = null; c.lng = null }
const sc = senderCounts.get(c.id)
out.push({
id: c.id, country: c.country, label: c.label,
count: c.count, target_pop: c.target_pop, lat: c.lat, lng: c.lng,
langs: [...c.langSet],
target_top_langs: [...c.langSet].slice(0, 4),
target_top_religion: '',
members: c.members,
senderCount: sc ? sc.count : 0,
hasSenders: !!sc,
color: redColor(c.count)
})
}
out.sort((a, b) => b.count - a.count)
return out
}
/** Fetch the live unreached "reds" from pray.doxa.life. */
async function fetchReds(cfg) {
const base = cfg.apiBase
|| (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL))
|| 'https://pray.doxa.life'
const fields = 'slug,name,latitude,longitude,country_code,primary_language,wagf_block'
const url = base.replace(/\/?$/, '') + '/api/people-groups/list?fields=' + fields + '&lang=en'
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
return j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : [])
}
/** Top-N category bars [{ key, label, value }] sorted desc, by an accessor. */
function topBars(rows, keyOf, n) {
const m = new Map()
for (const r of rows) { const k = keyOf(r) || '—'; m.set(k, (m.get(k) || 0) + 1) }
return [...m.entries()].map(([label, value]) => ({ key: label, label, value }))
.sort((a, b) => b.value - a.value).slice(0, n)
}
/** Donut slices [{ key, label, value, color }] by an accessor, categorical palette. */
function donutBy(rows, keyOf) {
const m = new Map()
for (const r of rows) { const k = keyOf(r) || '—'; m.set(k, (m.get(k) || 0) + 1) }
const sorted = [...m.entries()].sort((a, b) => b[1] - a[1])
return sorted.map(([label, value], i) => ({
key: label, label, value, color: COLORS.CAT[i % COLORS.CAT.length]
}))
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,515 @@
<script setup>
/**
* flower-mesh.vue — Application Profile: My UPG Neighbor · FLOWER-MESH
*
* @description Mesh-network "flower" — an UNREACHED people group (doxa-2000) at the
* flower CENTER, surrounded by its REACHED NEIGHBOR PGs radiating by
* adjacency (own country = inner whorl, surrounding countries = outer
* petals), with same-language mesh links between collaborators.
* @profile flower-mesh
* @bundle doxa-upg-neighbor
* @element flower-mesh
*
* WHY A FLOWER, NOT A MAP (card UPGN-PPLR):
* "My UPG 100 List" finds the unreached. "My UPG Neighbor" answers: who is already
* reached, near, and language-bridged enough to be SENT to reach them? This profile
* renders that as a radial mesh — the unreached target in the center; every reached
* neighbor a petal node; a stem (spoke) from each neighbor INTO the center = a SEND
* path; and chords between neighbors who share a language = the collaboration mesh.
*
* 0-DEPENDENCY / SHADOW-DOM-SAFE (hard constraints from the card):
* - Hand-rolled SVG. NO Mapbox, NO d3, NO chart lib. `npm install` is impossible in
* this tree (node_modules symlinks into frozen doxa-life), so this file imports
* ONLY Vue + the shared @map/useShadowStyles composable. All geometry is computed
* here in plain JS.
* - All CSS goes through useShadowStyles (scoped <style> in glob-loaded children is
* silently dropped by defineCustomElement — see useShadowStyles.js).
*
* DATA (see _qa-cycle/01-ideation-research/PPLR-RE-FINDINGS.md for the full RE):
* - REACHED neighbors: data/reached-neighbors.json — the baked Joshua-Project /
* IMB-PPLR-derived 4-D join (frozen 2026-06-12), keyed by unreached target country.
* Each neighbor: { name, country, lang, pct_christian, pop, bridge, adjacency,
* religion_bg, province? }.
* - UNREACHED center (doxa-2000): the per-country target summary already in that JSON
* (target_upg / target_pop / target_top_langs / target_top_religion). Optionally
* enriched live from pray.doxa.life (the project's pray-tools source) when reachable.
*
* PROVINCE GRANULARITY — HONEST LIMIT:
* The card asked for province-level granularity. The RE proved PPLR (ArcGIS IMB
* layers) and the baked JSON are BOTH country-level only; the finest sub-country
* signal is a free-text `Segment` / UN-M49 sub-region. So the flower groups at the
* honest finest grain (country), renders a `province`/`segment` tier WHERE PRESENT,
* and the data contract carries an optional `province` field so true admin-1 data can
* be dropped in later with zero code change. The provenance footer states this.
*
* profile-config shape:
* {
* "profile": "flower-mesh",
* "dataUrl": "<url-to-reached-neighbors.json>", // optional override
* "target": "India", // optional default target
* "apiBase": "https://pray.doxa.life", // optional live doxa enrich
* "instanceId": "any-stable-id"
* }
*/
import { inject, ref, computed, onMounted } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// ─── Palette (mirrors send-map.vue — orange unreached / green reached / navy chrome) ─
const C_ORANGE = '#FF6600' // unreached center (the target we WANT to reach)
const C_NAVY = '#002e6e' // chrome / SEND spokes
const C_BG = '#0b1020' // deep field backdrop (the night the harvest waits in)
// reached-strength green ramp by %Christian (deeper = more laborers to send)
function reachedColor(pct) {
const p = +pct || 0
if (p >= 80) return '#0f7a3d'
if (p >= 40) return '#1f9d55'
if (p >= 15) return '#5cb874'
return '#9bcf9b'
}
// ─── Config (provide/inject from ProfileLoader) ──────────────────────────────
const profileConfig = inject('profileConfig')
const dataSource = inject('dataSource', null)
const cfg = computed(() => profileConfig?.value || {})
// ─── State ────────────────────────────────────────────────────────────────────
const dataset = ref(null) // parsed reached-neighbors.json
const appReady = ref(false)
const loadError = ref('')
const activeTarget = ref(cfg.value.target ?? 'India')
const showMesh = ref(true) // same-language collaboration chords
const selectedId = ref(null) // clicked node id (highlight focus)
const liveCenter = ref(null) // optional live doxa-2000 enrichment
const tip = ref({ show: false, x: 0, y: 0, html: '' })
// ─── Geometry constants (SVG user units; viewBox is square VB×VB) ────────────
const VB = 1000
const CX = VB / 2
const CY = VB / 2
const R_IN = 205 // inner whorl — same-country reached PGs
const R_OUT = 360 // outer petals — surrounding-country reached PGs
const R_CTR = 46 // center node radius
// polar → cartesian. angle in degrees, 0° = straight up, clockwise.
function polar(cx, cy, r, angleDeg) {
const a = (angleDeg - 90) * Math.PI / 180
return [cx + r * Math.cos(a), cy + r * Math.sin(a)]
}
// node radius by population (sqrt scale, clamped) — keeps tiny + huge PGs legible.
function nodeR(pop) {
const p = Math.max(0, +pop || 0)
return Math.max(7, Math.min(22, 6 + Math.sqrt(p) / 320))
}
// normalize a language string for same-language matching.
function langKey(s) { return String(s || '').trim().toLowerCase() }
// ─── Derived: target list + the selected target's record ─────────────────────
const targetNames = computed(() =>
dataset.value ? Object.keys(dataset.value.countries) : [])
const targetRec = computed(() =>
dataset.value?.countries?.[activeTarget.value] ?? null)
// All qualifying reached neighbors for the active target, split by adjacency ring.
const neighbors = computed(() => {
const t = targetRec.value
if (!t) return []
return (t.neighbors || []).map((n, i) => ({ ...n, _i: i }))
})
const innerNodes = computed(() =>
neighbors.value.filter(n => n.adjacency === 'same-country'))
const outerNodes = computed(() =>
neighbors.value.filter(n => n.adjacency !== 'same-country'))
// ─── Lay out every node: assign each a {id, x, y, r, color, ring, ...node} ────
// Inner ring spread evenly over 360°; outer ring spread evenly, offset by a half
// step so the two whorls interleave into a flower rather than stacking radially.
const layout = computed(() => {
const out = []
const place = (list, radius, offsetDeg) => {
const N = list.length
list.forEach((n, k) => {
const ang = N ? (k + 0.5) / N * 360 + offsetDeg : 0
const [x, y] = polar(CX, CY, radius, ang)
out.push({
id: 'n' + n._i,
node: n,
x, y, ang,
r: nodeR(n.pop),
color: reachedColor(n.pct_christian),
ring: radius === R_IN ? 'inner' : 'outer'
})
})
}
place(innerNodes.value, R_IN, 0)
place(outerNodes.value, R_OUT, (outerNodes.value.length ? 180 / outerNodes.value.length : 0))
return out
})
// Map id → laid-out node, for link lookups.
const byId = computed(() => Object.fromEntries(layout.value.map(p => [p.id, p])))
// ─── SEND spokes: center → each neighbor (the SEND path) ──────────────────────
// Style encodes the language bridge: solid = same-language (strong), dashed = cluster.
const spokes = computed(() => layout.value.map(p => ({
id: 's-' + p.id,
x1: CX, y1: CY, x2: p.x, y2: p.y,
strong: p.node.bridge === 'same-language',
to: p.id
})))
// ─── Collaboration mesh: chords between neighbors sharing a language ──────────
// This is the "mesh-network" of the flower — reached PGs who could partner because
// they speak the same language. Curved toward center for an organic petal weave.
const meshLinks = computed(() => {
if (!showMesh.value) return []
const L = layout.value
const links = []
for (let a = 0; a < L.length; a++) {
for (let b = a + 1; b < L.length; b++) {
// PPLR-3: chord neighbors who share a language by rol3 (ISO 639-3) code, name fallback.
const la = L[a].node.rol3 || langKey(L[a].node.lang)
if (!la) continue
if (la === (L[b].node.rol3 || langKey(L[b].node.lang))) {
// quadratic control point pulled ~40% toward center → gentle inward arc
const mx = (L[a].x + L[b].x) / 2
const my = (L[a].y + L[b].y) / 2
const ctrlX = mx + (CX - mx) * 0.45
const ctrlY = my + (CY - my) * 0.45
links.push({
id: `m-${L[a].id}-${L[b].id}`,
d: `M ${L[a].x.toFixed(1)} ${L[a].y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${L[b].x.toFixed(1)} ${L[b].y.toFixed(1)}`,
from: L[a].id, to: L[b].id
})
}
}
}
return links
})
// ─── Petal backdrops behind outer nodes (the flower silhouette) ──────────────
const petals = computed(() => layout.value
.filter(p => p.ring === 'outer')
.map(p => ({
id: 'p-' + p.id,
// ellipse centered a little outward of the node, rotated to point away from center
cx: polar(CX, CY, R_OUT - 6, p.ang)[0],
cy: polar(CX, CY, R_OUT - 6, p.ang)[1],
rot: p.ang,
color: p.color
})))
// ─── Center summary (doxa-2000 unreached target) ─────────────────────────────
const center = computed(() => {
const t = targetRec.value
const live = liveCenter.value
return {
name: activeTarget.value,
upg: live?.upg ?? t?.target_upg ?? 0,
pop: live?.pop ?? t?.target_pop ?? 0,
religion: t?.target_top_religion ?? '',
langs: t?.target_top_langs ?? []
}
})
// counts for the header line
const stats = computed(() => ({
total: neighbors.value.length,
inner: innerNodes.value.length,
outer: outerNodes.value.length,
countries: new Set(neighbors.value.map(n => n.country)).size,
meshes: meshLinks.value.length
}))
// ─── Highlight focus: when a node is selected, dim everything not connected ───
function isLinkLit(link) {
if (!selectedId.value) return true
return link.from === selectedId.value || link.to === selectedId.value
}
function isSpokeLit(sp) {
if (!selectedId.value) return true
return sp.to === selectedId.value
}
function isNodeLit(p) {
if (!selectedId.value) return true
if (p.id === selectedId.value) return true
// lit if it shares a mesh link with the selected node
return meshLinks.value.some(m =>
(m.from === selectedId.value && m.to === p.id) ||
(m.to === selectedId.value && m.from === p.id))
}
// ─── Number formatting ────────────────────────────────────────────────────────
const fmt = (n) => (+n || 0).toLocaleString()
function fmtPop(n) {
const v = +n || 0
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
if (v >= 1e3) return (v / 1e3).toFixed(0) + 'K'
return String(v)
}
// ─── Interaction ──────────────────────────────────────────────────────────────
function onNodeEnter(p, ev) {
const n = p.node
const prov = n.province || n.segment
tip.value = {
show: true,
x: ev.clientX, y: ev.clientY,
html: `<strong>${esc(n.name)}</strong>`
+ `<div class="fm-tip-sub">${esc(n.country)}${prov ? ' · ' + esc(prov) : ''}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">reached</span> ${Math.round(n.pct_christian || 0)}% Christian</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">language</span> ${esc(n.lang || '—')}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">people</span> ${fmtPop(n.pop)}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">bridge</span> ${esc(n.bridge || '—')}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">near</span> ${esc(n.adjacency || '—')}</div>`
}
}
function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
function onNodeLeave() { tip.value = { ...tip.value, show: false } }
function onNodeClick(p) { selectedId.value = (selectedId.value === p.id) ? null : p.id }
function clearFocus() { selectedId.value = null }
function onSelectTarget(e) { activeTarget.value = e.target.value; selectedId.value = null }
// tiny HTML escaper for tooltip (defense-in-depth; data is trusted but cheap to guard)
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
// ─── Data load ────────────────────────────────────────────────────────────────
// PPLR-1: the reached-neighbor data is INLINED (static import) so it ships INSIDE the bundle. vite
// does not deploy each profile's data/ folder, so a runtime fetch('./data/...') 404s in the REAL
// platform gem-frame (it worked in `npm run dev` only — Vite serves the source data dir there). A
// profile-config `dataUrl` still overrides live. Same harness-vs-platform class as the ProfileLoader fix.
import bakedReached from './data/reached-neighbors.json'
async function loadDataset() {
const cfgUrl = cfg.value.dataUrl
if (cfgUrl) {
try { const res = await fetch(cfgUrl); if (res.ok) return await res.json() } catch (_) { /* fall back to baked */ }
}
return bakedReached
}
// Optional: enrich the center with live doxa-2000 counts (best-effort, non-blocking).
async function enrichCenterLive() {
const apiBase = cfg.value.apiBase
|| (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL))
if (!apiBase) return
try {
// statistics gives global doxa-2000 totals; used only as a soft live signal.
const res = await fetch(apiBase.replace(/\/?$/, '') + '/api/people-groups/statistics')
if (res.ok) {
const s = await res.json()
// We don't have per-country live counts here without a heavier query; keep the
// baked per-country numbers and just confirm liveness. (Seam for future wiring.)
if (s && typeof s.total === 'number') liveCenter.value = null
}
} catch (_) { /* offline / CORS — baked numbers stand */ }
}
onMounted(async () => {
dataset.value = await loadDataset()
if (!dataset.value) {
loadError.value = 'Could not load reached-neighbors.json'
appReady.value = true
return
}
if (!targetNames.value.includes(activeTarget.value)) {
activeTarget.value = targetNames.value[0] || activeTarget.value
}
appReady.value = true
enrichCenterLive()
})
// ─── Shadow-DOM CSS ───────────────────────────────────────────────────────────
useShadowStyles(`
.fm-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden;
background:radial-gradient(circle at 50% 42%, #16203a 0%, ${C_BG} 70%); color:#e5e7eb;
isolation:isolate; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
/* top bar */
.fm-top { position:absolute; top:12px; left:12px; right:12px; z-index:30;
display:flex; flex-wrap:wrap; gap:8px; align-items:center; pointer-events:none; }
.fm-top > * { pointer-events:auto; }
.fm-title { background:${C_NAVY}; color:#fff; font:700 12px/1 ui-monospace,monospace;
letter-spacing:.05em; text-transform:uppercase; padding:9px 13px; border-radius:9px;
box-shadow:0 2px 10px rgba(0,0,0,.4); }
.fm-select { background:#0e1730; border:1px solid #2b3b63; color:#dbe4f3;
font:600 12px/1 -apple-system,sans-serif; padding:8px 11px; border-radius:9px; cursor:pointer; max-width:240px; }
.fm-toggle { background:#0e1730; border:1px solid #2b3b63; color:#9fb3d9;
font:600 11px/1 ui-monospace,monospace; padding:8px 11px; border-radius:9px; cursor:pointer;
display:inline-flex; align-items:center; gap:7px; user-select:none; }
.fm-toggle.on { color:#fff; border-color:#3f7bd6; background:#13224a; }
.fm-toggle .fm-sw { width:9px; height:9px; border-radius:50%; background:#3f7bd6; opacity:.35; }
.fm-toggle.on .fm-sw { opacity:1; }
.fm-stats { margin-left:auto; font:600 11px/1.5 ui-monospace,monospace; color:#9fb3d9;
background:rgba(8,12,24,.7); border:1px solid #1c2742; border-radius:9px; padding:7px 11px; text-align:right; }
.fm-stats b { color:#fff; }
/* canvas */
.fm-stage { flex:1 1 0; position:relative; }
.fm-svg { position:absolute; inset:0; width:100%; height:100%; display:block; }
.fm-loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:#9fb3d9; font:13px/1 ui-monospace,monospace; z-index:5; }
/* svg node + link styling driven by classes */
.fm-spoke { stroke:#3a4f86; stroke-width:1.4; fill:none; opacity:.5; transition:opacity .15s; }
.fm-spoke.strong { stroke:#5b7fd0; stroke-width:2; opacity:.7; }
.fm-spoke.dim { opacity:.06; }
.fm-mesh { stroke:#ffae66; stroke-width:1.3; fill:none; opacity:.5; stroke-linecap:round; transition:opacity .15s; }
.fm-mesh.lit { opacity:.92; stroke-width:2; }
.fm-mesh.dim { opacity:.05; }
.fm-petal { opacity:.13; transition:opacity .15s; }
.fm-petal.dim { opacity:.04; }
.fm-node { cursor:pointer; transition:opacity .15s; }
.fm-node.dim { opacity:.18; }
.fm-node circle { stroke:#0b1020; stroke-width:2; }
.fm-node.sel circle { stroke:#ffffff; stroke-width:3; }
.fm-node-label { fill:#cdd9f2; font:600 12px/1 -apple-system,sans-serif; paint-order:stroke;
stroke:#0b1020; stroke-width:3px; text-anchor:middle; pointer-events:none; }
.fm-center-glow { fill:${C_ORANGE}; opacity:.16; }
.fm-center circle { fill:${C_ORANGE}; stroke:#ffd9bd; stroke-width:3; }
.fm-center-name { fill:#fff; font:800 19px/1 -apple-system,sans-serif; text-anchor:middle;
paint-order:stroke; stroke:#7a2e00; stroke-width:3px; }
.fm-center-sub { fill:#ffe1cb; font:600 11px/1 ui-monospace,monospace; text-anchor:middle; }
/* legend */
.fm-legend { position:absolute; left:12px; bottom:38px; z-index:25; background:rgba(8,12,24,.82);
border:1px solid #1c2742; border-radius:10px; padding:10px 12px; max-width:230px;
box-shadow:0 4px 16px rgba(0,0,0,.45); }
.fm-legend h4 { font:700 9px/1 ui-monospace,monospace; letter-spacing:.12em; text-transform:uppercase;
color:#7e90b6; margin-bottom:8px; }
.fm-lrow { display:flex; align-items:center; gap:8px; padding:3px 0; font-size:11px; color:#c4d0e8; }
.fm-dot { width:12px; height:12px; border-radius:50%; flex:0 0 auto; }
.fm-line { width:18px; height:0; border-top:2px solid; flex:0 0 auto; }
/* footer provenance (honesty contract) */
.fm-footer { position:absolute; left:0; right:0; bottom:0; z-index:20; background:rgba(8,12,24,.86);
color:#8fa2c8; font:10px/1.4 ui-monospace,monospace; padding:6px 12px;
display:flex; flex-wrap:wrap; gap:3px 14px; align-items:center; border-top:1px solid #1c2742; }
.fm-footer b { color:#dbe4f3; }
/* tooltip */
.fm-tip { position:fixed; z-index:60; pointer-events:none; max-width:240px;
background:#0e1730; border:1px solid #34508c; border-radius:9px; padding:9px 11px;
box-shadow:0 6px 22px rgba(0,0,0,.55); font-size:11.5px; color:#dbe4f3; }
.fm-tip strong { font-size:13px; color:#fff; }
.fm-tip-sub { color:#ffae66; font:600 10px/1.3 ui-monospace,monospace; margin:2px 0 6px; }
.fm-tip-row { display:flex; gap:6px; line-height:1.5; }
.fm-tip-k { color:#7e90b6; min-width:56px; }
@media (max-width:720px) {
.fm-stats { display:none; }
.fm-legend { max-width:170px; }
}
`, 'flower-mesh')
</script>
<template>
<div class="fm-root" @mousemove="onMove">
<!-- top bar -->
<div class="fm-top">
<span class="fm-title">My UPG Neighbor · Flower-Mesh</span>
<select v-if="targetNames.length" class="fm-select"
:value="activeTarget" @change="onSelectTarget"
aria-label="Select unreached target country">
<option v-for="name in targetNames" :key="name" :value="name">{{ name }}</option>
</select>
<span class="fm-toggle" :class="{ on: showMesh }" role="switch" :aria-checked="showMesh"
tabindex="0" @click="showMesh = !showMesh"
@keydown.enter.prevent="showMesh = !showMesh" @keydown.space.prevent="showMesh = !showMesh">
<span class="fm-sw"></span> language mesh
</span>
<span v-if="targetRec" class="fm-stats">
<b>{{ stats.total }}</b> reached neighbors · <b>{{ stats.countries }}</b> countries<br>
<b>{{ fmt(center.upg) }}</b> UPGs in center · <b>{{ stats.meshes }}</b> mesh links
</span>
</div>
<!-- the flower -->
<div class="fm-stage">
<div v-if="!appReady" class="fm-loading">Growing the flower…</div>
<div v-else-if="loadError" class="fm-loading">{{ loadError }}</div>
<svg v-else class="fm-svg" :viewBox="`0 0 ${VB} ${VB}`"
preserveAspectRatio="xMidYMid meet" @click="clearFocus">
<!-- petal backdrops (flower silhouette) -->
<g>
<ellipse v-for="pt in petals" :key="pt.id" class="fm-petal"
:class="{ dim: selectedId }"
:cx="pt.cx" :cy="pt.cy" rx="78" ry="40"
:fill="pt.color"
:transform="`rotate(${pt.rot} ${pt.cx} ${pt.cy})`" />
</g>
<!-- SEND spokes: center → each neighbor -->
<g>
<line v-for="sp in spokes" :key="sp.id" class="fm-spoke"
:class="{ strong: sp.strong, dim: !isSpokeLit(sp) }"
:x1="sp.x1" :y1="sp.y1" :x2="sp.x2" :y2="sp.y2"
:stroke-dasharray="sp.strong ? '0' : '5 5'" />
</g>
<!-- collaboration mesh: same-language chords -->
<g>
<path v-for="m in meshLinks" :key="m.id" class="fm-mesh"
:class="{ lit: isLinkLit(m) && selectedId, dim: selectedId && !isLinkLit(m) }"
:d="m.d" />
</g>
<!-- neighbor nodes -->
<g>
<g v-for="p in layout" :key="p.id" class="fm-node"
:class="{ dim: !isNodeLit(p), sel: p.id === selectedId }"
@click.stop="onNodeClick(p)"
@mouseenter="onNodeEnter(p, $event)" @mouseleave="onNodeLeave">
<title>{{ p.node.name }} — {{ Math.round(p.node.pct_christian || 0) }}% Christian, {{ p.node.lang }}</title>
<circle :cx="p.x" :cy="p.y" :r="p.r" :fill="p.color" />
<text v-if="p.r >= 9 || p.id === selectedId" class="fm-node-label"
:x="p.x" :y="p.y + p.r + 14">{{ p.node.name }}</text>
</g>
</g>
<!-- center: the unreached target (doxa-2000) -->
<g class="fm-center" @click.stop="clearFocus" style="cursor:default">
<circle class="fm-center-glow" :cx="CX" :cy="CY" :r="R_CTR + 26" />
<circle :cx="CX" :cy="CY" :r="R_CTR" />
<text class="fm-center-name" :x="CX" :y="CY - 2">{{ center.name }}</text>
<text class="fm-center-sub" :x="CX" :y="CY + 16">{{ fmt(center.upg) }} UPGs</text>
<text class="fm-center-sub" :x="CX" :y="CY + 30" style="opacity:.85">{{ fmtPop(center.pop) }} people</text>
</g>
</svg>
<!-- legend -->
<div v-if="appReady && !loadError" class="fm-legend">
<h4>The flower</h4>
<div class="fm-lrow"><span class="fm-dot" :style="{ background: C_ORANGE }"></span> Unreached target (doxa-2000)</div>
<div class="fm-lrow"><span class="fm-dot" :style="{ background: reachedColor(90) }"></span> Reached neighbor (deeper = more Christian)</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#5b7fd0"></span> SEND path → into the unreached</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#3a4f86;border-top-style:dashed"></span> via related language cluster</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#ffae66"></span> Same-language collaboration mesh</div>
<div class="fm-lrow" style="color:#7e90b6;margin-top:5px;font-size:10px">inner whorl = same country · petals = surrounding countries</div>
</div>
</div>
<!-- provenance / honesty contract -->
<div class="fm-footer">
<span><b>Flower-Mesh</b> reached neighbor → unreached center</span>
<span>center = doxa-2000 (pray.doxa.life)</span>
<span>neighbors = baked {{ dataset?.meta?.generated || '2026-06-12' }} (≥{{ dataset?.meta?.threshold_pct_christian || 10 }}% Christian, {{ dataset?.meta?.adjacency_km || 1500 }} km)</span>
<span>province N/A in source — finest grain = country (see RE)</span>
<span>Luke 10:2 · send laborers into the harvest</span>
</div>
<!-- tooltip -->
<div v-if="tip.show" class="fm-tip"
:style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }"
v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,574 @@
<script setup>
/**
* lang-cluster.vue — Application Profile: My UPG Neighbor · LANGUAGE-CLUSTER MAP
*
* @description Geographic bounded-set view — all ~2,106 doxa unreached people-groups
* plotted, grouped into LANGUAGE clusters (language×country; language×PROVINCE
* for India & China), each red cluster surrounded by the GREEN reached
* neighbors that match its language. The map twin of the radial flower-mesh.
* @profile lang-cluster
* @bundle doxa-upg-neighbor
* @element lang-cluster
*
* BOUNDED-SET DECOMPOSITION (shared with flower-mesh — two views of ONE clustering):
* 2000 doxa unreached → group by language × country (language × province for IN/CN)
* → each group = a BOUNDED SET (red)
* → reached neighbors matching the group's LANGUAGE = green
* This profile renders the MAP view; clicking a cluster drills into the FLOWER view
* (flower-mesh profile) via an `upgn:drill-flower` CustomEvent on the host element.
*
* DATA (RE: _qa-cycle/01-ideation-research/DOXA-PROVINCE-RE.md):
* - Unreached: LIVE pray.doxa.life /api/people-groups/list (~2,106; lean projection).
* Each carries latitude/longitude (per-PG), country_code{value,label},
* primary_language{value,label}. NO province field in the record.
* - Province (IN/CN only): baked data/admin1-membership.json — slug→admin1, the TRUE
* province from my-upg-100-list's point-in-polygon over real ADM1 polygons (reused,
* not re-derived). Non-IN/CN PGs cluster by language×country.
* - Reached: baked data/reached-neighbors.json — each neighbor carries `lang`; matched
* to a cluster by normalized language NAME (reached side has no ISO code).
*
* 0-DEP / SHADOW-DOM-SAFE: imports only Vue + @map (useMapInstance/useShadowStyles).
* Mapbox GL is the external peer (loaded by the host), reused via @map — no npm install.
*
* profile-config: { "profile":"lang-cluster", "tk":"pk...", "apiBase":"https://pray.doxa.life",
* "dataUrl":"<reached-neighbors.json>", "instanceId":"..." }
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// ─── Palette ──────────────────────────────────────────────────────────────────
const C_RED = '#e11d48' // unreached language cluster (the bounded set we WANT to reach)
const C_RED_DK = '#9f1239'
const C_NAVY = '#002e6e'
const C_GOLD = '#f59e0b' // selection highlight
function reachedColor(pct) { // green ramp by %Christian (sender strength)
const p = +pct || 0
if (p >= 80) return '#0f7a3d'
if (p >= 40) return '#1f9d55'
if (p >= 15) return '#5cb874'
return '#9bcf9b'
}
// ─── inject() from ProfileLoader ──────────────────────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const injectedInstanceId = inject('instanceId', null)
const cfg = computed(() => profileConfig?.value || {})
const mapId = (injectedInstanceId?.value || injectedInstanceId) ||
('lang-cluster-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8))
// ─── State ────────────────────────────────────────────────────────────────────
const lcRoot = ref(null)
const mapContainer = ref(null)
const appReady = ref(false)
const statusMsg = ref('Loading people groups…')
const clusters = ref([]) // [{key,lang,country,province,scope,count,lat,lng,reached:[]}]
const reachedAll = ref([]) // flat reached list with placement coords
const selectedKey = ref(null)
const tip = ref({ show: false, x: 0, y: 0, html: '' })
// ─── Country centroids (for placing GREEN reached — reached rows have no lat/lng) ─
const COUNTRY_CENTROIDS = {
'India': [79.0, 22.0], 'Pakistan': [69.3, 30.0], 'Indonesia': [113.0, -1.0],
'Bangladesh': [90.3, 23.7], 'China': [104.0, 35.0], 'Japan': [138.0, 37.0],
'Iran': [53.0, 32.0], 'Türkiye (Turkey)': [35.0, 39.0], 'Egypt': [30.0, 26.5],
'Nigeria': [8.0, 9.5], 'Thailand': [101.0, 15.0], 'Sudan': [30.0, 15.5],
'Algeria': [3.0, 28.0], 'Iraq': [44.0, 33.0], 'Myanmar (Burma)': [96.0, 21.0],
'Afghanistan': [66.0, 34.0], 'Yemen': [47.5, 15.5], 'Morocco': [-6.0, 32.0],
'Uzbekistan': [64.0, 41.5], 'Saudi Arabia': [45.0, 24.0], 'Ethiopia': [39.5, 9.0],
'Niger': [9.0, 17.5], 'Korea, North': [127.0, 40.0], 'Nepal': [84.0, 28.2],
'Mali': [-3.5, 17.0], 'Somalia': [46.5, 5.5], 'Malaysia': [108.0, 3.5],
'Russia': [90.0, 60.0], 'Cambodia': [105.0, 12.7], 'Kazakhstan': [67.0, 48.0],
'Senegal': [-14.5, 14.5], 'United States': [-98.0, 39.0], 'Guinea': [-11.0, 10.5],
'Tunisia': [9.5, 34.0], 'Chad': [19.0, 15.5], 'Tajikistan': [71.0, 38.7],
'Jordan': [36.5, 31.0], 'Azerbaijan': [47.5, 40.3], 'Vietnam': [106.0, 16.0],
'Israel': [35.0, 31.3], 'Armenia': [45.0, 40.2], 'Austria': [14.5, 47.5],
'Bahrain': [50.6, 26.0], 'Brunei': [114.7, 4.5], 'Bulgaria': [25.5, 42.7],
'Burkina Faso': [-1.7, 12.3], 'Cameroon': [12.5, 6.0], 'Canada': [-106.0, 56.0],
'Cape Verde': [-23.6, 16.0], 'China, Hong Kong': [114.2, 22.3], 'China, Macau': [113.5, 22.2],
'Cyprus': [33.2, 35.1], "Côte d'Ivoire": [-5.5, 7.5], 'Eritrea': [39.0, 15.3],
'France': [2.5, 47.0], 'Georgia': [43.5, 42.0], 'Ghana': [-1.0, 7.9], 'Greece': [22.0, 39.0],
'Guinea-Bissau': [-15.0, 12.0], 'Italy': [12.5, 42.5], 'Kenya': [38.0, 0.2],
'Korea, South': [128.0, 36.5], 'Kuwait': [47.7, 29.3], 'Kyrgyzstan': [74.5, 41.3],
'Lebanon': [35.8, 33.9], 'Liberia': [-9.4, 6.5], 'Moldova': [28.5, 47.2],
'Oman': [56.0, 21.0], 'Portugal': [-8.0, 39.5], 'Qatar': [51.2, 25.3], 'Serbia': [21.0, 44.0],
'Sierra Leone': [-11.8, 8.5], 'Singapore': [103.8, 1.35], 'South Sudan': [31.0, 7.5],
'Spain': [-3.7, 40.3], 'Switzerland': [8.2, 46.8], 'Syria': [38.0, 35.0],
'Tanzania': [35.0, -6.3], 'Togo': [1.0, 8.6], 'Turkmenistan': [59.0, 39.0],
'Uganda': [32.5, 1.4], 'Ukraine': [31.0, 49.0], 'United Arab Emirates': [54.0, 24.0]
}
function jittered(country, key) {
const base = COUNTRY_CENTROIDS[country]
if (!base) return null
let h = 0; const s = String(key || '')
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
return [base[0] + ((h & 0xff) / 255 - 0.5) * 4, base[1] + (((h >> 8) & 0xff) / 255 - 0.5) * 4]
}
const langKey = (s) => String(s || '').trim().toLowerCase()
// ─── Map instance (shadow-DOM safe, @map) ────────────────────────────────────
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken?.value || cfg.value.tk || '',
style: cfg.value.style ?? 'mapbox://styles/mapbox/light-v11',
center: cfg.value.center ?? [55, 22],
zoom: cfg.value.zoom ?? 1.7,
minZoom: cfg.value.minZoom ?? 1,
maxZoom: cfg.value.maxZoom ?? 12
})
// ─── Layer / source ids ──────────────────────────────────────────────────────
const SRC_CL = 'lc-clusters-src', SRC_PIN = 'lc-pins-src', SRC_RE = 'lc-reached-src', SRC_LK = 'lc-links-src'
const L_CL = 'lc-clusters', L_CL_LABEL = 'lc-cluster-labels', L_PIN = 'lc-pins'
const L_RE = 'lc-reached', L_LK = 'lc-links'
// ─── Derived stats ────────────────────────────────────────────────────────────
const stats = computed(() => {
const cl = clusters.value
const provinces = new Set(cl.filter(c => c.scope === 'province').map(c => c.province))
const langs = new Set(cl.map(c => c.lang))
return {
clusters: cl.length,
pgs: cl.reduce((s, c) => s + c.count, 0),
provinces: provinces.size,
langs: langs.size,
withReach: cl.filter(c => c.reached.length).length
}
})
const selected = computed(() => clusters.value.find(c => c.key === selectedKey.value) || null)
// ─── Normalize a {value,label} or string → label string ──────────────────────
function lbl(f) { return (f && typeof f === 'object') ? (f.label ?? f.value ?? '') : (f ?? '') }
function val(f) { return (f && typeof f === 'object') ? (f.value ?? f.label ?? '') : (f ?? '') }
const COUNTRY_DISPLAY = {
"People's Republic of China": 'China',
'Lao People’s Democratic Republic': 'Laos',
"Lao People's Democratic Republic": 'Laos'
}
function normCountry(s) { return COUNTRY_DISPLAY[s] || s }
// ─── Build clusters from the live records + baked province membership ─────────
function buildClusters(records, adminProv) {
const map = new Map()
for (const r of records) {
const lat = Number(r.latitude), lng = Number(r.longitude)
const lang = lbl(r.primary_language) || '(unknown language)'
const country = normCountry(lbl(r.country_code) || '(unknown)')
const slug = r.slug || val(r.primary_language) + ':' + r.name
const province = adminProv[slug] || null // only present for IN/CN
const scope = province ? 'province' : 'country'
const scopeName = province || country
const key = lang + ' · ' + scopeName + (province ? ' (' + country + ')' : '')
let c = map.get(key)
if (!c) {
// PPLR-3: carry rol3 (ISO 639-3) = primary_language.value, the clean join key to reached neighbors.
c = { key, lang, rol3: val(r.primary_language) || '', country, province, scope, scopeName, count: 0,
_sumLat: 0, _sumLng: 0, _n: 0, members: [], reached: [] }
map.set(key, c)
}
c.count++
c.members.push({ name: r.name, slug, lang, country })
if (isFinite(lat) && isFinite(lng)) { c._sumLat += lat; c._sumLng += lng; c._n++ }
}
const out = []
for (const c of map.values()) {
// centroid = mean of members with coords; skip clusters with no geocoded member
if (!c._n) continue
c.lat = c._sumLat / c._n
c.lng = c._sumLng / c._n
delete c._sumLat; delete c._sumLng; delete c._n
out.push(c)
}
out.sort((a, b) => b.count - a.count)
return out
}
// ─── Build the flat reached set + match to clusters by language ───────────────
// PPLR-3: join reached neighbors to unreached clusters by rol3 (ISO 639-3) CODE — exact — with a
// language-NAME fallback for rows lacking a code. The old name-only match hit only 31/100 reached
// langs (label drift, e.g. "Korean" vs the doxa label); the PPLR-2 data now carries rol3 on every
// neighbor and doxa primary_language.value IS the rol3, so the code join densifies green well past that.
function buildReached(reachedJson, clusterList) {
const clusterRol3 = new Set(clusterList.map(c => c.rol3).filter(Boolean))
const clusterNames = new Set(clusterList.map(c => langKey(c.lang)))
const seen = new Set()
const flat = []
const byRol3 = new Map()
const byName = new Map()
for (const t of Object.values(reachedJson.countries || {})) {
for (const n of (t.neighbors || [])) {
const lk = langKey(n.lang)
const rol3 = n.rol3 || ''
const codeHit = rol3 && clusterRol3.has(rol3)
if (!codeHit && !clusterNames.has(lk)) continue // rol3 exact, else language-name fallback
const id = n.name + '|' + n.country + '|' + (rol3 || lk)
if (seen.has(id)) continue
seen.add(id)
// PPLR-2 gave each neighbor REAL Lat/Long — use it; fall back to country-centroid jitter.
const coord = (Number(n.lng) && Number(n.lat)) ? [Number(n.lng), Number(n.lat)] : jittered(n.country, id)
if (!coord) continue
const row = { name: n.name, country: n.country, lang: n.lang, rol3, lk,
pct: n.pct_christian || 0, pop: n.pop || 0, lng: coord[0], lat: coord[1] }
flat.push(row)
if (rol3) { if (!byRol3.has(rol3)) byRol3.set(rol3, []); byRol3.get(rol3).push(row) }
if (!byName.has(lk)) byName.set(lk, []); byName.get(lk).push(row)
}
}
// attach matched reached to each cluster — rol3 code first, language-name fallback, unioned + deduped
for (const c of clusterList) {
const fromCode = c.rol3 ? (byRol3.get(c.rol3) || []) : []
const fromName = byName.get(langKey(c.lang)) || []
c.reached = (fromCode.length && fromName.length) ? [...new Set([...fromCode, ...fromName])]
: (fromCode.length ? fromCode : fromName)
}
return flat
}
// ─── GeoJSON helpers ──────────────────────────────────────────────────────────
const fc = (features) => ({ type: 'FeatureCollection', features })
function clusterFeatures() {
return fc(clusters.value.map(c => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [c.lng, c.lat] },
properties: { key: c.key, lang: c.lang, count: c.count, scope: c.scope,
hasReach: c.reached.length > 0, label: c.lang + ' (' + c.count + ')' }
})))
}
function pinFeatures() {
const feats = []
for (const c of clusters.value) {
for (const m of c.members) {
// member coords aren't stored individually; place small dots around the cluster centroid
// (the cluster bubble carries the true mean; pins are visual context at high zoom)
const p = jittered(c.country, m.slug) // light spread; falls back to centroid below
const coord = p || [c.lng, c.lat]
feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [c.lng + (coord[0] - (COUNTRY_CENTROIDS[c.country]?.[0] ?? coord[0])) * 0.15, c.lat] },
properties: { key: c.key } })
}
}
return fc(feats)
}
function reachedFeatures() {
const sel = selected.value
const selRol3 = sel ? (sel.rol3 || '') : ''
const selName = sel ? langKey(sel.lang) : null
return fc(reachedAll.value.map(r => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
properties: { name: r.name, country: r.country, lang: r.lang, pct: r.pct,
color: reachedColor(r.pct),
matched: sel ? ((selRol3 && r.rol3 === selRol3) || (!!selName && r.lk === selName)) : false }
})))
}
function linkFeatures() {
const c = selected.value
if (!c) return fc([])
return fc(c.reached.map(r => ({
type: 'Feature',
geometry: { type: 'LineString', coordinates: [[c.lng, c.lat], [r.lng, r.lat]] },
properties: {}
})))
}
// ─── Paint (idempotent; survives style reloads) ──────────────────────────────
function setSrc(m, id, data) {
const s = m.getSource(id)
if (s) s.setData(data); else m.addSource(id, { type: 'geojson', data })
}
function paintAll() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
setSrc(m, SRC_LK, linkFeatures())
setSrc(m, SRC_PIN, pinFeatures())
setSrc(m, SRC_RE, reachedFeatures())
setSrc(m, SRC_CL, clusterFeatures())
if (!m.getLayer(L_LK)) m.addLayer({
id: L_LK, type: 'line', source: SRC_LK,
paint: { 'line-color': C_GOLD, 'line-width': 1.1, 'line-opacity': 0.5, 'line-dasharray': [2, 2] }
})
if (!m.getLayer(L_PIN)) m.addLayer({
id: L_PIN, type: 'circle', source: SRC_PIN, minzoom: 3.6,
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 3.6, 1.6, 8, 4],
'circle-color': C_RED, 'circle-opacity': 0.5 }
})
if (!m.getLayer(L_RE)) m.addLayer({
id: L_RE, type: 'circle', source: SRC_RE,
paint: {
'circle-radius': ['case', ['get', 'matched'], 4.5, ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 3.5]],
'circle-color': ['get', 'color'],
'circle-stroke-width': ['case', ['get', 'matched'], 1.5, 0.5],
'circle-stroke-color': ['case', ['get', 'matched'], C_GOLD, '#ffffff'],
'circle-opacity': ['case', ['boolean', ['feature-state', 'dim'], false], 0.15,
['case', ['get', 'matched'], 0.95, 0.6]]
}
})
if (!m.getLayer(L_CL)) m.addLayer({
id: L_CL, type: 'circle', source: SRC_CL,
paint: {
'circle-radius': ['interpolate', ['linear'], ['get', 'count'], 1, 7, 25, 16, 80, 26, 130, 34],
'circle-color': C_RED,
'circle-opacity': ['case', ['==', ['get', 'key'], ['literal', '']], 0.6, 0.55],
'circle-stroke-width': ['case', ['get', 'hasReach'], 2.4, 1],
'circle-stroke-color': ['case', ['get', 'hasReach'], reachedColor(80), C_RED_DK]
}
})
if (!m.getLayer(L_CL_LABEL)) m.addLayer({
id: L_CL_LABEL, type: 'symbol', source: SRC_CL, minzoom: 2.6,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.3],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
})
applySelectionPaint()
}
function applySelectionPaint() {
const m = map.value
if (!m || !m.getLayer(L_CL)) return
// gold stroke on the selected cluster
m.setPaintProperty(L_CL, 'circle-stroke-color',
selectedKey.value
? ['case', ['==', ['get', 'key'], selectedKey.value], C_GOLD,
['case', ['get', 'hasReach'], reachedColor(80), C_RED_DK]]
: ['case', ['get', 'hasReach'], reachedColor(80), C_RED_DK])
m.setPaintProperty(L_CL, 'circle-stroke-width',
selectedKey.value
? ['case', ['==', ['get', 'key'], selectedKey.value], 4, ['case', ['get', 'hasReach'], 2.4, 1]]
: ['case', ['get', 'hasReach'], 2.4, 1])
}
// ─── Selection + drill ────────────────────────────────────────────────────────
function selectCluster(key) {
selectedKey.value = (selectedKey.value === key) ? null : key
const m = map.value
if (m) { setSrc(m, SRC_RE, reachedFeatures()); setSrc(m, SRC_LK, linkFeatures()); applySelectionPaint() }
const c = selected.value
if (c && m) {
try {
let minLng = c.lng, maxLng = c.lng, minLat = c.lat, maxLat = c.lat
for (const r of c.reached) {
minLng = Math.min(minLng, r.lng); maxLng = Math.max(maxLng, r.lng)
minLat = Math.min(minLat, r.lat); maxLat = Math.max(maxLat, r.lat)
}
m.stop()
if (minLng === maxLng && minLat === maxLat) m.easeTo({ center: [c.lng, c.lat], zoom: 4, duration: 800 })
else m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 110, maxZoom: 5.5, duration: 800 })
} catch (_) {}
}
}
function drillToFlower() {
const c = selected.value
if (!c) return
const detail = { target: c.country, language: c.lang, province: c.province || null, scope: c.scope, clusterKey: c.key }
try {
const host = lcRoot.value && lcRoot.value.getRootNode && lcRoot.value.getRootNode().host
const node = host || lcRoot.value
node && node.dispatchEvent(new CustomEvent('upgn:drill-flower', { bubbles: true, composed: true, detail }))
} catch (_) {}
}
// ─── Tooltip ──────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
// ─── Boot ─────────────────────────────────────────────────────────────────────
// PPLR-1: baked data INLINED (static imports) so it ships inside the bundle — vite doesn't deploy
// the profile data/ folder, so fetch('./data/...') 404s in the REAL platform (worked in dev only).
// adminUrl/dataUrl/dataBase still override live (loadJSON below).
import bakedReached from './data/reached-neighbors.json'
import bakedAdmin1 from './data/admin1-membership.json'
async function loadJSON(relCandidates) {
for (const url of relCandidates) {
try { const r = await fetch(url); if (r.ok) return await r.json() } catch (_) {}
}
return null
}
async function fetchDoxa() {
const base = cfg.value.apiBase
|| (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL))
|| 'https://pray.doxa.life'
const fields = 'slug,name,latitude,longitude,country_code,primary_language,wagf_block,population'
const url = base.replace(/\/?$/, '') + '/api/people-groups/list?fields=' + fields + '&lang=en'
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
return j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : [])
}
onMounted(async () => {
await initializeMap()
const m = map.value
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
// baked data (own bundle dir). `base` lets the host say where data/ is served from
// (platform deploy serves data/ alongside the bundle, like send-map/flower-mesh).
// override-then-baked: a host-provided adminUrl/dataUrl/dataBase fetches live; otherwise the
// INLINED baked imports stand (no runtime fetch -> no 404 in the platform).
const base = (cfg.value.dataBase) || (typeof window !== 'undefined' && window.MAP_APP_BASE_URL) || ''
const baseUrl = (f) => base ? base.replace(/\/?$/, '/') + 'data/' + f : null
const adminProv = (await loadJSON([cfg.value.adminUrl, baseUrl('admin1-membership.json')].filter(Boolean))) || bakedAdmin1 || {}
const reachedJson = (await loadJSON([cfg.value.dataUrl, baseUrl('reached-neighbors.json')].filter(Boolean))) || bakedReached || { countries: {} }
// live unreached doxa-2000
let records = []
try { records = await fetchDoxa() }
catch (e) { statusMsg.value = 'Live doxa API unreachable — ' + e.message; appReady.value = true; return }
clusters.value = buildClusters(records, adminProv)
reachedAll.value = buildReached(reachedJson, clusters.value)
appReady.value = true
// PPLR-3 verify hook: reached pin count + how many unreached clusters now have a matched reached
// neighbor (the green densification metric — rol3 join + name fallback).
try {
window.__lcStats = {
reachedPins: reachedAll.value.length,
matchedClusters: clusters.value.filter(c => c.reached && c.reached.length).length,
totalClusters: clusters.value.length,
matchedByRol3: reachedAll.value.filter(r => r.rol3).length,
}
} catch (_) { /* verify hook */ }
// click + hover wiring
const wire = () => {
paintAll()
m.on('click', L_CL, (e) => { const f = e.features?.[0]; if (f) selectCluster(f.properties.key) })
const hover = (layer, html) => {
m.on('mouseenter', layer, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', layer, () => { m.getCanvas().style.cursor = ''; tip.value = { ...tip.value, show: false } })
m.on('mousemove', layer, (e) => {
const f = e.features?.[0]; if (!f) return
tip.value = { show: true, x: e.originalEvent.clientX, y: e.originalEvent.clientY, html: html(f.properties) }
})
}
hover(L_CL, p => `<strong>${esc(p.lang)}</strong><div class="lc-tip-sub">${esc(p.key)}</div>`
+ `<div class="lc-tip-row"><span class="lc-tip-k">unreached</span> ${p.count} groups</div>`
+ `<div class="lc-tip-row"><span class="lc-tip-k">reached</span> ${p.hasReach ? 'matching neighbors ✓' : 'none found'}</div>`
+ `<div class="lc-tip-row" style="color:#9fb3d9">click → bounded set + flower</div>`)
hover(L_RE, p => `<strong>${esc(p.name)}</strong><div class="lc-tip-sub">${esc(p.country)} · ${esc(p.lang)}</div>`
+ `<div class="lc-tip-row"><span class="lc-tip-k">reached</span> ${Math.round(p.pct)}% Christian</div>`)
}
if (m?.isStyleLoaded?.()) wire()
else m?.once?.('load', wire)
// belt-and-suspenders repaint (style timing race — Lesson-03 in the sibling)
m?.once?.('idle', () => { if (appReady.value) paintAll() })
})
onBeforeUnmount(() => { destroy() })
// ─── Shadow-DOM CSS (single template; color consts interpolated, no nested backticks) ─
useShadowStyles(`
.lc-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden;
background:#ffffff; color:#1f2937; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.lc-map { position:absolute; inset:0; }
.lc-loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:#ffffff; color:${C_NAVY}; font:13px/1 ui-monospace,monospace; z-index:5; }
.lc-top { position:absolute; top:10px; left:10px; right:10px; z-index:30; display:flex;
flex-wrap:wrap; gap:8px; align-items:center; pointer-events:none; }
.lc-top > * { pointer-events:auto; }
.lc-title { background:${C_NAVY}; color:#fff; font:700 12px/1 ui-monospace,monospace;
letter-spacing:.04em; text-transform:uppercase; padding:8px 12px; border-radius:8px; }
.lc-stats { margin-left:auto; background:rgba(255,255,255,.94); border:1px solid #d1d5db;
border-radius:8px; padding:7px 11px; font:600 11px/1.5 ui-monospace,monospace; color:#374151; text-align:right; }
.lc-stats b { color:${C_RED_DK}; }
.lc-legend { position:absolute; left:10px; bottom:34px; z-index:25; background:rgba(255,255,255,.95);
border:1px solid #d1d5db; border-radius:10px; padding:9px 12px; max-width:240px; box-shadow:0 4px 14px rgba(0,0,0,.12); }
.lc-legend h4 { font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; color:#6b7280; margin-bottom:8px; }
.lc-lrow { display:flex; align-items:center; gap:8px; padding:3px 0; font-size:11px; color:#374151; }
.lc-dot { width:12px; height:12px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(0,0,0,.25); }
.lc-panel { position:absolute; top:52px; right:10px; bottom:34px; z-index:26; width:290px; max-width:48vw;
background:rgba(255,255,255,.98); border:1px solid #d1d5db; border-radius:10px; box-shadow:0 4px 16px rgba(0,0,0,.16);
display:flex; flex-direction:column; overflow:hidden; }
.lc-panel-head { padding:11px 13px; background:${C_RED}; color:#fff; }
.lc-panel-head .lc-h-lang { font:800 15px/1.2 -apple-system,sans-serif; }
.lc-panel-head .lc-h-sub { margin-top:3px; font:600 11px/1.3 ui-monospace,monospace; color:#ffe4e6; }
.lc-panel-body { flex:1 1 0; overflow-y:auto; padding:9px 11px; }
.lc-sec { font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; color:#6b7280; margin:6px 0 6px; }
.lc-chip { display:inline-block; font:600 10px/1 ui-monospace,monospace; padding:3px 7px; border-radius:999px;
border:1px solid #d1d5db; color:#374151; background:#f9fafb; margin:0 4px 4px 0; }
.lc-re { border:1px solid #e5e7eb; border-radius:8px; padding:6px 8px; margin-bottom:5px; }
.lc-re-name { font:700 12px/1.2 -apple-system,sans-serif; color:#111827; display:flex; align-items:center; gap:6px; }
.lc-re-meta { margin-top:3px; font:11px/1.3 ui-monospace,monospace; color:#4b5563; }
.lc-drill { display:block; width:100%; margin-top:8px; padding:9px; border:0; border-radius:8px; cursor:pointer;
background:${C_NAVY}; color:#fff; font:700 12px/1 -apple-system,sans-serif; }
.lc-drill:hover { background:#013a8a; }
.lc-muted { color:#6b7280; font-size:11px; padding:8px 2px; }
.lc-footer { position:absolute; left:0; right:0; bottom:0; z-index:20; background:${C_NAVY}; color:#dbe4f3;
font:10px/1.4 ui-monospace,monospace; padding:5px 12px; display:flex; flex-wrap:wrap; gap:3px 14px; align-items:center; }
.lc-footer b { color:#fff; }
.lc-tip { position:fixed; z-index:60; pointer-events:none; max-width:240px; background:#0e1730;
border:1px solid #34508c; border-radius:9px; padding:9px 11px; box-shadow:0 6px 22px rgba(0,0,0,.5); font-size:11.5px; color:#dbe4f3; }
.lc-tip strong { font-size:13px; color:#fff; }
.lc-tip-sub { color:#fda4af; font:600 10px/1.3 ui-monospace,monospace; margin:2px 0 6px; }
.lc-tip-row { display:flex; gap:6px; line-height:1.5; }
.lc-tip-k { color:#7e90b6; min-width:64px; }
@media (max-width:720px){ .lc-stats{ display:none; } .lc-panel{ width:210px; } .lc-legend{ max-width:160px; } }
`, 'lang-cluster')
</script>
<template>
<div ref="lcRoot" class="lc-root" @mousemove="onMove">
<div ref="mapContainer" class="lc-map"></div>
<div v-if="!appReady" class="lc-loading">{{ statusMsg }}</div>
<!-- top bar -->
<div class="lc-top">
<span class="lc-title">My UPG Neighbor · Language Clusters</span>
<span v-if="appReady" class="lc-stats">
<b>{{ stats.clusters }}</b> language clusters · <b>{{ stats.pgs }}</b> unreached<br>
<b>{{ stats.provinces }}</b> IN/CN provinces · <b>{{ stats.withReach }}</b> with reached match
</span>
</div>
<!-- legend -->
<div v-if="appReady" class="lc-legend">
<h4>Bounded sets</h4>
<div class="lc-lrow"><span class="lc-dot" :style="{ background: C_RED }"></span> Unreached language cluster (size = #groups)</div>
<div class="lc-lrow"><span class="lc-dot" :style="{ background: reachedColor(80) }"></span> Reached neighbor (matched by language)</div>
<div class="lc-lrow" style="color:#6b7280;font-size:10px;margin-top:4px">green ring = cluster has a reached match · click a cluster to drill</div>
</div>
<!-- selected bounded-set panel -->
<div v-if="appReady && selected" class="lc-panel">
<div class="lc-panel-head">
<div class="lc-h-lang">{{ selected.lang }}</div>
<div class="lc-h-sub">{{ selected.scope === 'province' ? selected.province + ' · ' + selected.country : selected.country }}</div>
</div>
<div class="lc-panel-body">
<div class="lc-sec">Bounded set — {{ selected.count }} unreached groups</div>
<span v-for="(m, i) in selected.members.slice(0, 18)" :key="i" class="lc-chip">{{ m.name }}</span>
<span v-if="selected.members.length > 18" class="lc-chip">+{{ selected.members.length - 18 }} more</span>
<div class="lc-sec" style="margin-top:10px">Reached neighbors (same language) — {{ selected.reached.length }}</div>
<div v-if="!selected.reached.length" class="lc-muted">No reached people-group shares this language in the baked set. A frontier — pray for laborers (Luke 10:2).</div>
<div v-for="(r, i) in selected.reached" :key="'r'+i" class="lc-re">
<div class="lc-re-name"><span class="lc-dot" :style="{ background: reachedColor(r.pct) }"></span>{{ r.name }}</div>
<div class="lc-re-meta">{{ r.country }} · {{ Math.round(r.pct) }}% Christian · {{ r.lang }}</div>
</div>
<button class="lc-drill" @click="drillToFlower">Open flower view ▸</button>
</div>
</div>
<!-- provenance -->
<div v-if="appReady" class="lc-footer">
<span><b>Language clusters</b> red unreached · green reached-by-language</span>
<span>unreached = LIVE pray.doxa.life ({{ stats.pgs }})</span>
<span>IN/CN province = real ADM1 (my-upg-100-list point-in-polygon)</span>
<span>reached = baked reached-neighbors</span>
<span>Luke 10:2 · bounded sets → flowers</span>
</div>
<div v-if="tip.show" class="lc-tip" :style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }" v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,523 @@
<script setup>
/**
* send-map.vue — Application Profile: My UPG Neighbor (cross-border SEND model)
*
* @profile send-map
* @bundle doxa-upg-neighbor
* @element send-map
*
* Renders the SEND model on an interactive Mapbox map:
* After "My UPG 100 List" identifies the unreached (≤0.1% Christian, ≥500k
* pop), "My UPG Neighbor" finds, for each unreached PG, the REACHED neighbor
* PG that can be *sent* to reach it.
*
* Authoritative vision + data contract: AT Tier-3 spec
* app-profiles/my-upg-neighbor/_qa-cycle/01-ideation-research/AT-TIER3-SPEC.md
*
* A qualifying neighbor satisfies the 4-dimension JOIN (spec §1):
* 1. Adjacency — same-country, else neighbor within 1500 km centroid
* 2. Language pair — same PrimaryLanguageName (strong) else PeopleCluster
* 3. Reached % — neighbor ≥ 10% Christian (higher = more laborers)
* 4. Lower persecution (reported, not hard-filtered)
* → OUTPUT: SEND the reached neighbor INTO the unreached target.
*
* Structural mirror of app-profiles/impact-map/profiles/impact-map.vue:
* reads profile-config via inject() from ProfileLoader, uses useMapInstance for
* shadow-DOM-safe init + useShadowStyles for CSS, fetches its OWN baked JSON
* (data/reached-neighbors.json — the 4-D join output, frozen 2026-06-12) and
* builds raw Mapbox layers (no people-group DataSourceManager pipeline — this
* profile carries country-level SEND nodes, not the pray-tools PG dataset).
*
* The baked JSON has no per-row lat/lng — "neighbor" is a reached PEOPLE pool
* keyed by country. We join each row to a country centroid (COUNTRY_CENTROIDS
* below) so a target's qualifying senders render as nodes around it, with a
* dashed SEND arrow drawn from each sender's country centroid INTO the target.
*
* profile-config shape:
* {
* "profile": "send-map",
* "tk": "pk.YOUR_MAPBOX_TOKEN",
* "dataUrl": "<url-to-reached-neighbors.json>", // optional override
* "target": "India", // optional default target
* "instanceId": "any-stable-id"
* }
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// ─── Palette (spec §5 — light mode; orange/navy; NO brown) ───────────────────
const C_ORANGE = '#FF6600' // unreached / target
const C_NAVY = '#002e6e' // chrome / sender connectors
const C_GREEN = '#1f9d55' // reached (high %Christian)
const C_GREEN_LO = '#9bcf9b' // reached (low %Christian)
// ─── Shadow-DOM style injection ──────────────────────────────────────────────
useShadowStyles(`
.sn-root { position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;background:#ffffff;color:#1f2937;isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.sn-map-area { flex:1 1 0;position:relative;overflow:hidden; }
.sn-map-canvas { position:absolute;inset:0; }
.sn-loading { position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffff;color:${C_NAVY};font-size:13px;z-index:5; }
/* ── Top bar: title + target selector ── */
.sn-top { position:absolute;top:10px;left:10px;z-index:30;display:flex;flex-wrap:wrap;gap:8px;align-items:center; }
.sn-title { background:${C_NAVY};color:#fff;font:700 12px/1 ui-monospace,monospace;letter-spacing:.04em;text-transform:uppercase;padding:8px 12px;border-radius:8px; }
.sn-select { background:#fff;border:1px solid ${C_NAVY};color:${C_NAVY};font:600 12px/1 -apple-system,sans-serif;padding:7px 10px;border-radius:8px;cursor:pointer;max-width:240px; }
/* ── Left: 4-D JOIN legend (the gate the connectors pass through) ── */
.sn-legend { position:absolute;top:58px;left:10px;z-index:25;background:rgba(255,255,255,0.95);border:1px solid #d1d5db;border-radius:10px;padding:9px 12px;box-shadow:0 4px 14px rgba(0,0,0,0.12);max-width:230px; }
.sn-legend h4 { font:700 9px/1 ui-monospace,monospace;letter-spacing:.1em;text-transform:uppercase;color:#6b7280;margin-bottom:8px; }
.sn-legend-row { display:flex;align-items:flex-start;gap:7px;padding:3px 0;font-size:11px;color:#374151;line-height:1.3; }
.sn-dot { width:11px;height:11px;border-radius:50%;flex:0 0 auto;border:1px solid rgba(0,0,0,0.35);margin-top:1px; }
.sn-legend .sn-num { font-weight:700;color:${C_NAVY};flex:0 0 auto; }
/* ── Right: worked sender table for the selected target ── */
.sn-panel { position:absolute;top:10px;right:10px;bottom:42px;z-index:25;width:280px;max-width:46vw;background:rgba(255,255,255,0.97);border:1px solid #d1d5db;border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,0.12);display:flex;flex-direction:column;overflow:hidden; }
.sn-panel-head { padding:10px 12px;border-bottom:1px solid #e5e7eb;background:${C_NAVY};color:#fff; }
.sn-panel-head .sn-target { font:700 13px/1.2 -apple-system,sans-serif; }
.sn-panel-head .sn-sub { margin-top:4px;font:11px/1.3 ui-monospace,monospace;color:#c7d2fe; }
.sn-panel-body { flex:1 1 0;overflow-y:auto;padding:6px 8px; }
.sn-sender { border:1px solid #e5e7eb;border-radius:8px;padding:7px 9px;margin-bottom:6px;cursor:pointer;transition:border-color .12s,box-shadow .12s; }
.sn-sender:hover { border-color:${C_ORANGE};box-shadow:0 2px 8px rgba(255,102,0,0.18); }
.sn-sender-name { font:700 12px/1.2 -apple-system,sans-serif;color:#111827;display:flex;align-items:center;gap:6px; }
.sn-sender-meta { margin-top:4px;display:flex;flex-wrap:wrap;gap:4px; }
.sn-chip { font:600 10px/1 ui-monospace,monospace;padding:3px 6px;border-radius:999px;border:1px solid #d1d5db;color:#374151;background:#f9fafb; }
.sn-chip.pct { color:#0b5e33;background:#e7f6ec;border-color:#b7e0c4; }
.sn-chip.bridge { color:${C_NAVY};background:#e7edf8;border-color:#c2d2ee; }
.sn-chip.adj { color:#7a3e00;background:#fdeedd;border-color:#f3d4ac; }
/* ── Footer: provenance (honesty contract, spec §3 + §5) ── */
.sn-footer { flex:0 0 auto;background:${C_NAVY};color:#dbe4f3;font:10px/1.4 ui-monospace,monospace;padding:6px 12px;display:flex;flex-wrap:wrap;gap:4px 14px;align-items:center; }
.sn-footer strong { color:#fff; }
@media(max-width:760px){
.sn-panel { width:200px; }
.sn-legend { max-width:180px; }
}
`, 'send-map')
// ─── Config from ProfileLoader (provide/inject) ──────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const injectedInstanceId = inject('instanceId', null)
const mapId = (injectedInstanceId?.value || injectedInstanceId) ||
('send-map-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8))
// ─── Refs (declared before useMapInstance so containerRef binds the canvas) ───
const snRoot = ref(null)
const mapContainer = ref(null)
const appReady = ref(false)
const dataset = ref(null) // parsed reached-neighbors.json
const activeTarget = ref(profileConfig?.value?.target ?? 'India')
// ─── Country centroids ────────────────────────────────────────────────────────
// [lng, lat] for every target + neighbor country present in the baked JSON.
// The JSON keys SEND rows by country (the reached pool is people-by-country),
// so we need a centroid per country to place sender nodes + draw SEND arrows.
// Approximate population/area centroids — sufficient for a country-level SEND
// schematic (this is NOT an admin-tile choropleth). Names match the JSON's
// country strings VERBATIM (incl. "Türkiye (Turkey)", "Myanmar (Burma)",
// "Korea, North/South", "China, Hong Kong/Macau", "Côte d'Ivoire").
const COUNTRY_CENTROIDS = {
'India': [79.0, 22.0], 'Pakistan': [69.3, 30.0], 'Indonesia': [113.0, -1.0],
'Bangladesh': [90.3, 23.7], 'China': [104.0, 35.0], 'Japan': [138.0, 37.0],
'Iran': [53.0, 32.0], 'Türkiye (Turkey)': [35.0, 39.0], 'Egypt': [30.0, 26.5],
'Nigeria': [8.0, 9.5], 'Thailand': [101.0, 15.0], 'Sudan': [30.0, 15.5],
'Algeria': [3.0, 28.0], 'Iraq': [44.0, 33.0], 'Myanmar (Burma)': [96.0, 21.0],
'Afghanistan': [66.0, 34.0], 'Yemen': [47.5, 15.5], 'Morocco': [-6.0, 32.0],
'Uzbekistan': [64.0, 41.5], 'Saudi Arabia': [45.0, 24.0], 'Ethiopia': [39.5, 9.0],
'Niger': [9.0, 17.5], 'Korea, North': [127.0, 40.0], 'Nepal': [84.0, 28.2],
'Mali': [-3.5, 17.0], 'Somalia': [46.5, 5.5], 'Malaysia': [108.0, 3.5],
'Russia': [90.0, 60.0], 'Cambodia': [105.0, 12.7], 'Kazakhstan': [67.0, 48.0],
'Senegal': [-14.5, 14.5], 'United States': [-98.0, 39.0], 'Guinea': [-11.0, 10.5],
'Tunisia': [9.5, 34.0], 'Chad': [19.0, 15.5], 'Tajikistan': [71.0, 38.7],
'Jordan': [36.5, 31.0], 'Azerbaijan': [47.5, 40.3], 'Vietnam': [106.0, 16.0],
'Israel': [35.0, 31.3],
// ── neighbor-only countries ──
'Armenia': [45.0, 40.2], 'Austria': [14.5, 47.5], 'Bahrain': [50.6, 26.0],
'Brunei': [114.7, 4.5], 'Bulgaria': [25.5, 42.7], 'Burkina Faso': [-1.7, 12.3],
'Cameroon': [12.5, 6.0], 'Canada': [-106.0, 56.0], 'Cape Verde': [-23.6, 16.0],
'China, Hong Kong': [114.2, 22.3], 'China, Macau': [113.5, 22.2], 'Cyprus': [33.2, 35.1],
"Côte d'Ivoire": [-5.5, 7.5], 'Eritrea': [39.0, 15.3], 'France': [2.5, 47.0],
'Georgia': [43.5, 42.0], 'Ghana': [-1.0, 7.9], 'Greece': [22.0, 39.0],
'Guinea-Bissau': [-15.0, 12.0], 'Italy': [12.5, 42.5], 'Kenya': [38.0, 0.2],
'Korea, South': [128.0, 36.5], 'Kuwait': [47.7, 29.3], 'Kyrgyzstan': [74.5, 41.3],
'Lebanon': [35.8, 33.9], 'Liberia': [-9.4, 6.5], 'Moldova': [28.5, 47.2],
'Oman': [56.0, 21.0], 'Portugal': [-8.0, 39.5], 'Qatar': [51.2, 25.3],
'Serbia': [21.0, 44.0], 'Sierra Leone': [-11.8, 8.5], 'Singapore': [103.8, 1.35],
'South Sudan': [31.0, 7.5], 'Spain': [-3.7, 40.3], 'Syria': [38.0, 35.0],
'Tanzania': [35.0, -6.3], 'Togo': [1.0, 8.6], 'Turkmenistan': [59.0, 39.0],
'Uganda': [32.5, 1.4], 'Ukraine': [31.0, 49.0], 'United Arab Emirates': [54.0, 24.0]
}
function centroidFor(country, jitterKey) {
const base = COUNTRY_CENTROIDS[country]
if (!base) return null
// Deterministic small jitter so multiple same-country senders don't stack on
// one point (e.g. India's senders are all adjacency:same-country).
let h = 0
const s = String(jitterKey || '')
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
const dx = ((h & 0xff) / 255 - 0.5) * 3.2
const dy = (((h >> 8) & 0xff) / 255 - 0.5) * 3.2
return [base[0] + dx, base[1] + dy]
}
// ─── Map instance (shadow-DOM safe) ──────────────────────────────────────────
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: profileConfig?.value?.style ?? 'mapbox://styles/mapbox/light-v11',
center: profileConfig?.value?.center ?? [60, 25],
zoom: profileConfig?.value?.zoom ?? 2.4,
minZoom: profileConfig?.value?.minZoom ?? 1,
maxZoom: profileConfig?.value?.maxZoom ?? 12
})
// ─── Derived state ────────────────────────────────────────────────────────────
const targetNames = computed(() => dataset.value ? Object.keys(dataset.value.countries) : [])
const targetData = computed(() => dataset.value?.countries?.[activeTarget.value] ?? null)
// Senders sorted by %Christian desc (spec §3 + §5 right-panel contract).
const senders = computed(() => {
const t = targetData.value
if (!t) return []
return [...(t.neighbors || [])]
.filter(n => COUNTRY_CENTROIDS[n.country])
.sort((a, b) => (b.pct_christian || 0) - (a.pct_christian || 0))
})
function senderColor(pct) {
// Higher %Christian = stronger sender = deeper green (more laborers).
return (pct >= 50) ? C_GREEN : C_GREEN_LO
}
// ─── Layer ids ────────────────────────────────────────────────────────────────
const SRC_ARROWS = 'sn-arrows-src'
const SRC_SENDERS = 'sn-senders-src'
const SRC_TARGET = 'sn-target-src'
const SRC_TARGETS_ALL = 'sn-targets-all-src' // ALL unreached targets (clickable)
const L_ARROWS = 'sn-arrows'
const L_SENDERS = 'sn-senders'
const L_SENDER_LABEL = 'sn-sender-labels'
const L_TARGET = 'sn-target'
const L_TARGET_LABEL = 'sn-target-label'
const L_TARGETS_ALL = 'sn-targets-all' // the always-on clickable target field
// ALL unreached targets as clickable circles (the field the user picks from on the
// map itself — the click-to-select replacement for the dropdown). One feature per
// target country that has a known centroid.
function buildAllTargets() {
const ds = dataset.value
if (!ds) return fc([])
const feats = []
for (const name of Object.keys(ds.countries)) {
const c = COUNTRY_CENTROIDS[name]
if (!c) continue
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: c },
properties: { name, kind: 'target', upg: ds.countries[name]?.target_upg || 0 }
})
}
return fc(feats)
}
function buildFeatures() {
const t = targetData.value
if (!t) return { arrows: fc([]), senders: fc([]), target: fc([]) }
const tc = COUNTRY_CENTROIDS[activeTarget.value] || [60, 25]
const targetFeat = {
type: 'Feature',
geometry: { type: 'Point', coordinates: tc },
properties: { name: activeTarget.value, kind: 'target' }
}
const senderFeats = []
const arrowFeats = []
for (const n of senders.value) {
const c = centroidFor(n.country, n.name + n.lang)
if (!c) continue
senderFeats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: c },
properties: {
name: n.name, country: n.country, pct: n.pct_christian || 0,
color: senderColor(n.pct_christian || 0)
}
})
// SEND arrow: sender centroid → target centroid.
arrowFeats.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: [c, tc] },
properties: { bridge: n.bridge, adjacency: n.adjacency }
})
}
return { arrows: fc(arrowFeats), senders: fc(senderFeats), target: fc([targetFeat]) }
}
function fc(features) { return { type: 'FeatureCollection', features } }
function syncLayers() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
const { arrows, senders: sf, target } = buildFeatures()
// ── sources (create-or-setData) ──
setSrc(m, SRC_ARROWS, arrows)
setSrc(m, SRC_SENDERS, sf)
setSrc(m, SRC_TARGET, target)
setSrc(m, SRC_TARGETS_ALL, buildAllTargets())
// ── ALL unreached targets (clickable field; selected one is emphasized by the
// big L_TARGET on top, the rest DIM when a target is selected) ──
if (!m.getLayer(L_TARGETS_ALL)) {
m.addLayer({
id: L_TARGETS_ALL, type: 'circle', source: SRC_TARGETS_ALL,
paint: {
'circle-color': C_ORANGE,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 5, 5, 9, 9, 13],
'circle-stroke-width': 1.5, 'circle-stroke-color': '#ffffff'
}
})
}
// ── SEND arrows (navy dashed connectors) ──
if (!m.getLayer(L_ARROWS)) {
m.addLayer({
id: L_ARROWS, type: 'line', source: SRC_ARROWS,
layout: { 'line-cap': 'round' },
paint: {
'line-color': C_NAVY, 'line-width': 1.4, 'line-opacity': 0.55,
'line-dasharray': [2, 2]
}
})
}
// ── sender nodes (green-scaled by %Christian) ──
if (!m.getLayer(L_SENDERS)) {
m.addLayer({
id: L_SENDERS, type: 'circle', source: SRC_SENDERS,
paint: {
'circle-color': ['get', 'color'],
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2.5, 5, 4, 9, 6],
'circle-stroke-width': 0.8, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.92
}
})
}
if (!m.getLayer(L_SENDER_LABEL)) {
m.addLayer({
id: L_SENDER_LABEL, type: 'symbol', source: SRC_SENDERS,
minzoom: 3.4,
layout: {
'text-field': ['get', 'name'], 'text-size': 10,
'text-offset': [0, 1.1], 'text-anchor': 'top', 'text-allow-overlap': false
},
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
})
}
// ── target node (orange, the unreached) ──
if (!m.getLayer(L_TARGET)) {
m.addLayer({
id: L_TARGET, type: 'circle', source: SRC_TARGET,
paint: {
'circle-color': C_ORANGE,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 8, 5, 13, 9, 18],
'circle-stroke-width': 2.5, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.95
}
})
}
if (!m.getLayer(L_TARGET_LABEL)) {
m.addLayer({
id: L_TARGET_LABEL, type: 'symbol', source: SRC_TARGET,
layout: {
'text-field': ['get', 'name'], 'text-size': 13, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'],
'text-offset': [0, -1.4], 'text-anchor': 'bottom', 'text-allow-overlap': true
},
paint: { 'text-color': '#8a3800', 'text-halo-color': '#ffffff', 'text-halo-width': 1.6 }
})
}
applySelectionDim(m)
}
// When a target is selected: the selected target is drawn big by L_TARGET (on top),
// so hide its small dot in the all-targets field, and DIM every OTHER target so the
// SEND focus is unmistakable. With no selection, all targets show at full strength.
function applySelectionDim(m) {
if (!m || !m.getLayer(L_TARGETS_ALL)) return
const sel = activeTarget.value
m.setPaintProperty(L_TARGETS_ALL, 'circle-opacity',
sel ? ['case', ['==', ['get', 'name'], sel], 0, 0.28] : 0.9)
m.setPaintProperty(L_TARGETS_ALL, 'circle-stroke-opacity',
sel ? ['case', ['==', ['get', 'name'], sel], 0, 0.5] : 0.9)
}
function setSrc(m, id, data) {
if (m.getSource(id)) m.getSource(id).setData(data)
else m.addSource(id, { type: 'geojson', data })
}
// ─── Camera: frame the selected target + its senders ─────────────────────────
function frameTarget() {
const m = map.value
if (!m) return
const tc = COUNTRY_CENTROIDS[activeTarget.value]
if (!tc) return
let minLng = tc[0], maxLng = tc[0], minLat = tc[1], maxLat = tc[1]
for (const n of senders.value) {
const c = COUNTRY_CENTROIDS[n.country]
if (!c) continue
minLng = Math.min(minLng, c[0]); maxLng = Math.max(maxLng, c[0])
minLat = Math.min(minLat, c[1]); maxLat = Math.max(maxLat, c[1])
}
try {
m.stop()
if (minLng === maxLng && minLat === maxLat) {
m.easeTo({ center: tc, zoom: 4, duration: 900 })
} else {
m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 90, maxZoom: 5.5, duration: 900 })
}
} catch (_) {}
}
// ─── Handlers ─────────────────────────────────────────────────────────────────
// Shared selection path — used by BOTH a target-circle click (primary) and the
// dropdown (a11y/keyboard fallback). Selecting re-syncs senders+arrows for the
// new target, dims the other targets, and frames the SEND.
function selectTarget(name) {
if (!name || !COUNTRY_CENTROIDS[name]) return
activeTarget.value = name
syncLayers()
frameTarget()
}
function onSelectTarget(e) { selectTarget(e.target.value) }
function flyToSender(n) {
const m = map.value
const c = COUNTRY_CENTROIDS[n.country]
if (!m || !c) return
try { m.stop(); m.easeTo({ center: c, zoom: 4.5, duration: 800 }) } catch (_) {}
}
// ─── Boot ────────────────────────────────────────────────────────────────────
// PPLR-1: reached data INLINED (static import) so it ships inside the bundle — vite doesn't deploy
// the profile data/ folder, so fetch('./data/...') 404s in the REAL platform (worked in dev only).
// profile-config `dataUrl` still overrides live.
import bakedReached from './data/reached-neighbors.json'
async function loadDataset() {
const cfgUrl = profileConfig?.value?.dataUrl
if (cfgUrl) {
try { const res = await fetch(cfgUrl); if (res.ok) return await res.json() } catch (_) { /* fall back to baked */ }
}
return bakedReached
}
onMounted(async () => {
await initializeMap()
const m = map.value
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
// Test-only hook (inert in production — platform never sets debugExpose). Lets an
// automated harness use the live map.project() to click exact circle positions.
if (profileConfig?.value?.debugExpose && typeof window !== 'undefined') {
try { (snRoot.value?.getRootNode?.()?.host || window).__sendmap = m } catch (_) {}
}
dataset.value = await loadDataset()
if (!dataset.value) { appReady.value = true; return }
if (!targetNames.value.includes(activeTarget.value)) {
activeTarget.value = targetNames.value[0] || activeTarget.value
}
const wire = () => {
syncLayers()
// PRIMARY interaction (Driver bug fix): click an unreached TARGET circle →
// select it → dim the other targets + show ONLY its connected green senders
// (+ SEND arrows) + list them in the right panel. Replaces dropdown-only.
m.on('click', L_TARGETS_ALL, (ev) => {
const f = ev.features?.[0]; if (!f) return
selectTarget(f.properties.name)
})
m.on('mouseenter', L_TARGETS_ALL, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', L_TARGETS_ALL, () => { m.getCanvas().style.cursor = '' })
// Sender click → fly to that sender's country.
m.on('click', L_SENDERS, (ev) => {
const f = ev.features?.[0]; if (!f) return
try { m.stop(); m.easeTo({ center: f.geometry.coordinates, zoom: 4.5, duration: 800 }) } catch (_) {}
})
m.on('mouseenter', L_SENDERS, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', L_SENDERS, () => { m.getCanvas().style.cursor = '' })
frameTarget()
appReady.value = true
}
if (m?.isStyleLoaded?.()) wire()
else m?.once?.('load', wire)
})
onBeforeUnmount(() => { destroy() })
</script>
<template>
<div ref="snRoot" class="sn-root">
<div class="sn-map-area">
<div ref="mapContainer" class="sn-map-canvas"></div>
<div v-if="!appReady" class="sn-loading">Loading SEND map…</div>
<!-- Top: title + target selector -->
<div class="sn-top">
<span class="sn-title">My UPG Neighbor · SEND</span>
<select v-if="targetNames.length" class="sn-select"
:value="activeTarget" @change="onSelectTarget"
aria-label="Select unreached target country">
<option v-for="name in targetNames" :key="name" :value="name">{{ name }}</option>
</select>
</div>
<!-- Left: the 4-D JOIN legend (the gate the connectors pass through) -->
<div class="sn-legend">
<h4>4-D SEND join</h4>
<div class="sn-legend-row"><span class="sn-num">1·</span><span><strong>Adjacency</strong> — same-country, else neighbor ≤1500 km</span></div>
<div class="sn-legend-row"><span class="sn-num">2·</span><span><strong>Language pair</strong> — same language (strong) / cluster (soft)</span></div>
<div class="sn-legend-row"><span class="sn-num">3·</span><span><strong>Reached %</strong> — sender ≥10% Christian (more = more laborers)</span></div>
<div class="sn-legend-row"><span class="sn-num">4·</span><span><strong>Lower persecution</strong> — sender from a freer place</span></div>
<div style="border-top:1px solid #e5e7eb;margin:7px 0 5px;"></div>
<div class="sn-legend-row"><span class="sn-dot" :style="{ background: C_ORANGE }"></span><span>Unreached target — <strong>click one</strong> to SEND-focus</span></div>
<div class="sn-legend-row"><span class="sn-dot" :style="{ background: C_GREEN }"></span><span>Reached sender (SEND →)</span></div>
</div>
<!-- Right: worked sender table for the selected target -->
<div v-if="targetData" class="sn-panel">
<div class="sn-panel-head">
<div class="sn-target">{{ activeTarget }}</div>
<div class="sn-sub">
{{ (targetData.target_upg || 0).toLocaleString() }} UPGs ·
{{ senders.length }} reached senders
</div>
</div>
<div class="sn-panel-body">
<div v-for="(n, i) in senders" :key="n.name + i" class="sn-sender" @click="flyToSender(n)">
<div class="sn-sender-name">
<span class="sn-dot" :style="{ background: senderColor(n.pct_christian || 0) }"></span>
{{ n.name }}
</div>
<div class="sn-sender-meta">
<span class="sn-chip pct">{{ Math.round(n.pct_christian || 0) }}% Christian</span>
<span class="sn-chip bridge">{{ n.bridge }}</span>
<span class="sn-chip adj">{{ n.adjacency }}</span>
<span class="sn-chip">{{ n.lang }}</span>
</div>
</div>
<div v-if="!senders.length" style="padding:14px;font-size:12px;color:#6b7280;">
No qualifying reached senders with a known country centroid for this target.
</div>
</div>
</div>
<!-- Footer: provenance honesty contract + dependency chain -->
<div class="sn-footer">
<span><strong>SEND</strong> reached neighbor → unreached target</span>
<span>target = LIVE (JP UnreachedPeoplesByCountry)</span>
<span>senders = baked {{ dataset?.meta?.generated || '2026-06-12' }} (≥{{ dataset?.meta?.threshold_pct_christian || 10 }}% Christian, {{ dataset?.meta?.adjacency_km || 1500 }} km)</span>
<span>Hab 2:2 · mobilize existing laborers</span>
<span>chain: UPG-100 → FPG base → My UPG Neighbor</span>
</div>
</div>
</div>
</template>

View file

@ -1,46 +1,41 @@
{
"//": "Five preset renders of the legacy-wrapper profile -- one per legacy map rescued by the experimental roundup (EXPERIMENTAL-MAPS-ROUNDUP.md). props follow the cap-2 instances contract (props, not config -- measured: config was silently dropped from BASE_CONFIG and every instance page rendered the default send-map).",
"//": "Five preset renders of the upg-neighbor profile -- one per lens of the old doxa-upg-neighbor bundle (moved natively from app-profiles on 2026-09-24; EXPERIMENTAL-MAPS-ROUNDUP.md). Each row opens a different lens via props.view; the view-switcher reaches the rest. Ids are unchanged from the retired iframe wrapper so existing embed URLs keep working.",
"profile": "upg-neighbor",
"instances": [
{
"id": "send-map",
"title": "Send Map (legacy)",
"title": "Send Map",
"props": {
"legacyElement": "send-map",
"legacyTitle": "Send Map"
"view": "send-map"
}
},
{
"id": "upg-neighbors",
"title": "UPG Neighbors (legacy)",
"title": "UPG Neighbors",
"props": {
"legacyElement": "upg-neighbors",
"legacyTitle": "UPG Neighbors"
"view": "cockpit"
}
},
{
"id": "lang-cluster",
"title": "Language Clusters (legacy)",
"title": "Language Clusters",
"props": {
"legacyElement": "lang-cluster",
"legacyTitle": "Language Clusters"
"view": "lang-cluster"
}
},
{
"id": "bloom-cockpit",
"title": "Bloom Cockpit (legacy)",
"title": "Bloom Cockpit",
"props": {
"legacyElement": "bloom-cockpit",
"legacyTitle": "Bloom Cockpit"
"view": "cockpit"
}
},
{
"id": "flower-mesh",
"title": "Flower Mesh (legacy)",
"title": "Flower Mesh",
"props": {
"legacyElement": "flower-mesh",
"legacyTitle": "Flower Mesh"
"view": "flower-mesh"
}
}
]
}
}

View file

@ -0,0 +1,5 @@
{
"type": "monolithic",
"label": "My UPG Neighbors",
"note": "Monolithic composite (capability 3): ONE root .vue nests five internal lenses — Send Map, the Bloom cockpit, Flower Mesh, Lang Cluster and Flowers — behind a view-switcher. index.js registers exactly ONE tag (upg-neighbor); none of the lenses is independently embeddable. The five rows of upg-neighbor.instances.json are preset opening lenses, not separate elements."
}

View file

@ -1,56 +1,109 @@
<script setup>
/**
* upg-neighbor.vue — LEGACY-WRAPPER bundle (experimental roundup).
* upg-neighbor.vue — My UPG Neighbors: the five neighbor lenses in one native profile.
*
* @description Rescues the five pre-bundler experimental maps that live inside the legacy
* upg-neighbor IIFE — send-map, the upg-neighbors/bloom cockpit, lang-cluster and flower-mesh — without rebuilding them and
* without letting their old engine touch the modern bundles: each profile mounts its element
* inside a sandboxed IFRAME whose host page (assets/legacy/upg-neighbor/index.html) loads the
* prebuilt script, shims its relative /api fetches to the live API, and passes the token.
* The iframe is the whole isolation contract — a legacy crash dies inside its own frame.
* Refactor-to-native happens later, per map, only if a map earns it (roundup doc).
* @description Who goes to whom: reached neighbors matched to unreached people groups, five lenses
*
* Send Map, the Bloom cockpit, Flower Mesh, Lang Cluster and Flowers behind one view-switcher.
* Pick the opening lens with profile-config 'view'.
* @profile upg-neighbor
* @bundle upg-neighbor
* @element upg-neighbor
*
* MOVED 2026-09-24 from the old app-profiles/doxa-upg-neighbor bundle (root-dx-life/_imports/
* upg-neighbor-maps) into this plugin-designs folder. The lenses are the original SFCs, now in
* src/; this file is the original doxa-upg-neighbors hub. Until this move the five maps ran as a
* prebuilt legacy IIFE inside a sandboxed iframe; they now build from source like every bundle.
*
* Each lens inherits profileConfig/mapboxToken via inject() from ProfileLoader. v-if mounts ONE
* view at a time → one map instance at a time (each lens destroys its map onBeforeUnmount).
*
* profile-config: { "profile":"upg-neighbor", "tk":"pk...", "view":"send-map",
* "apiBase":"https://pray.doxa.life", "target":"India" }
*/
import { computed } from 'vue'
import { inject, ref, computed, onMounted } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import BloomCockpit from './src/bloom-cockpit.vue'
import SendMap from './src/send-map.vue'
import FlowerMesh from './src/flower-mesh.vue'
import LangCluster from './src/lang-cluster.vue'
import FlowerClusters from './src/cockpit/FlowerClusters.vue'
// ★ PROP, NOT INJECT. This bundle defines its element DIRECTLY (index.js), so it never sits
// under ProfileLoader and inject('profileConfig') is always null -- measured: the attribute
// carried legacyElement:"lang-cluster" while the iframe src said el=send-map (the default),
// and assetsBase degraded to a root-relative path that only worked because the sibling
// web-root assets copy happened to exist. Direct-defined bundles read the profile-config
// ATTRIBUTE as a prop (the people-groups-grid pattern); the send-map default masked the miss
// because send-map is also instance #1.
const props = defineProps({ profileConfig: { type: String, default: '' } })
const cfg = computed(() => {
try { return props.profileConfig ? JSON.parse(props.profileConfig) : {} }
catch (_) { return {} }
})
const element = computed(() => String(cfg.value.legacyElement || 'send-map').replace(/[^a-z0-9-]/g, ''))
const title = computed(() => cfg.value.legacyTitle || element.value)
const profileConfig = inject('profileConfig')
const cfg = computed(() => profileConfig?.value || {})
// assetsBase is a RESERVED config key -- raw cfg reads miss it in some hosts; every
// data-fetching bundle resolves it through the shared helper (DashboardMapPanel.vue:33),
// which also degrades correctly when nothing is provided. Measured before this fix: the
// iframe resolved against the LEAF page dir and 404'd in all three instances.
const frameSrc = computed(() => {
const base = String(resolveAssetsBase({ value: cfg.value }) || cfg.value.assetsBase || './assets').replace(/\/+$/, '')
const tk = cfg.value.tk || (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
return base + '/legacy/upg-neighbor/index.html?el=' + element.value + (tk ? '&tk=' + encodeURIComponent(tk) : '')
const VIEWS = [
{ key: 'cockpit', label: 'Overview', hint: 'crossfilter hub' },
{ key: 'send-map', label: 'Send Map', hint: 'reached → unreached arcs' },
{ key: 'flower-mesh', label: 'Flower Mesh', hint: 'radial bloom' },
{ key: 'lang-cluster',label: 'Lang Cluster', hint: 'language bounded-sets' },
{ key: 'flowers', label: 'Flowers', hint: 'family · language · dialect cores + granular petals' }
]
const VALID = new Set(VIEWS.map(v => v.key))
// 'legacyElement' is the key the retired iframe wrapper read; old embeds still carry it.
const LEGACY_VIEW = { 'upg-neighbors': 'cockpit', 'bloom-cockpit': 'cockpit' }
// Chosen at SETUP, not onMounted: switching after mount tears down Send Map while its map is
// still initializing (measured: "reading 'getRootNode'" on every non-default lens).
// Send Map is the default — "who goes, to whom" (EXPERIMENTAL-MAPS-ROUNDUP.md ranks it first).
const initial = cfg.value.view || LEGACY_VIEW[cfg.value.legacyElement] || cfg.value.legacyElement
const view = ref(VALID.has(initial) ? initial : 'send-map')
const rootEl = ref(null)
function setView(k) { if (VALID.has(k)) view.value = k }
onMounted(() => {
// gated test hook (inert in prod) — lets a harness drive the switcher deterministically
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try {
const host = rootEl.value?.getRootNode?.()?.host || window
host.__neighbors = { get view() { return view.value }, setView }
} catch (_) {}
}
})
useShadowStyles(
'.unw{position:relative;width:100%;height:100%;min-height:520px;background:#f3f3f1}' +
'.unw iframe{position:absolute;inset:0;width:100%;height:100%;border:0}' +
'.unw .tag{position:absolute;top:8px;right:8px;z-index:2;font:600 10px ui-monospace,monospace;' +
'letter-spacing:.06em;text-transform:uppercase;color:#6d766e;background:#fff;' +
'border:1px solid #d1d4cd;border-radius:100px;padding:3px 8px}',
'upg-neighbor-wrapper')
useShadowStyles(`
.dun-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden;
background:#0e1730; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.dun-bar { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:7px 12px;
background:#001b44; border-bottom:1px solid #002e6e; z-index:50; }
.dun-brand { font:800 11px/1 ui-monospace,monospace; letter-spacing:.06em; text-transform:uppercase;
color:#fff; background:rgba(255,255,255,.1); padding:6px 10px; border-radius:7px; white-space:nowrap; }
.dun-seg { display:inline-flex; background:#0e1730; border:1px solid #284a86; border-radius:999px;
padding:3px; gap:2px; flex-wrap:wrap; }
.dun-seg button { border:0; background:transparent; color:#9fb3d9; cursor:pointer;
font:700 11px/1 ui-monospace,monospace; padding:6px 12px; border-radius:999px; }
.dun-seg button.on { background:#002e6e; color:#fff; }
.dun-seg button:hover:not(.on) { color:#dbe4f3; }
.dun-hint { margin-left:auto; font:600 10px/1.3 ui-monospace,monospace; color:#7e90b6; white-space:nowrap; }
.dun-stage { flex:1 1 0; position:relative; min-height:0; background:#fff; }
/* each child view root is position:absolute;inset:0 → fills the stage */
@media (max-width:640px){ .dun-hint{ display:none; } .dun-brand{ display:none; } }
`, 'upg-neighbor')
const activeHint = computed(() => VIEWS.find(v => v.key === view.value)?.hint || '')
</script>
<template>
<div class="unw">
<span class="tag">experimental · legacy</span>
<iframe :src="frameSrc" :title="title" loading="lazy"></iframe>
<div ref="rootEl" class="dun-root">
<!-- view-switcher -->
<div class="dun-bar">
<span class="dun-brand">My UPG Neighbors</span>
<div class="dun-seg" role="tablist" aria-label="Choose a view">
<button v-for="v in VIEWS" :key="v.key" role="tab" :aria-selected="view === v.key"
:class="{ on: view === v.key }" @click="setView(v.key)">{{ v.label }}</button>
</div>
<span class="dun-hint">{{ activeHint }}</span>
</div>
<!-- one view at a time (each lens manages its own map; v-if = one Mapbox instance) -->
<div class="dun-stage">
<BloomCockpit v-if="view === 'cockpit'" />
<SendMap v-else-if="view === 'send-map'" />
<FlowerMesh v-else-if="view === 'flower-mesh'" />
<LangCluster v-else-if="view === 'lang-cluster'" />
<FlowerClusters v-else-if="view === 'flowers'" />
</div>
</div>
</template>

View file

@ -1,75 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>legacy map host</title>
<style>html,body{margin:0;height:100%;background:#f3f3f1}.host{position:fixed;inset:0}</style>
</head>
<body>
<div class="host" id="host"></div>
<script>
// Sandboxed host for the LEGACY upg-neighbor IIFE (pre-bundler architecture). This page runs
// inside an IFRAME so the old engine and its dependencies cannot collide with the modern
// bundles -- the isolation pattern the roundup mandates ("add without breaking doxa-maps").
var q = new URLSearchParams(location.search);
var el = (q.get('el') || 'send-map').replace(/[^a-z0-9-]/g, '');
var tk = q.get('tk') || '';
// Token globals the legacy build may read.
window.MAPBOX_TOKEN = tk; window.MAP_TOKEN = tk; window.UPG_TOKEN = tk;
// The legacy code fetches relative "/api/people-groups/..." which only resolved on its old
// origin. Shim fetch so those calls reach the live API from ANY host (static tree, iframe).
var API = 'https://pray.doxa.life';
// The cockpit profile fails closed without a configured API base and names this global in
// its own error banner (measured: "No API base configured (apiBase / window.MAP_APP_API_URL
// / VITE_API_BASE_URL) -- failing closed").
window.MAP_APP_API_URL = API;
var origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
try {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.indexOf('/api/') === 0) return origFetch(API + url, init);
} catch (e) { /* fall through */ }
return origFetch(input, init);
};
// ── KEYLESS SHIM ──────────────────────────────────────────────────────────
// The legacy bundle expects a mapboxgl GLOBAL and requests mapbox://styles/mapbox/light-v11.
// MapLibre is API-compatible with the mapbox-gl v1 surface the bundle uses, and OpenFreeMap
// Positron is the keyless twin of light-v11 -- so the shim below makes every legacy map run
// WITHOUT a Mapbox token: load our self-hosted MapLibre, alias it as mapboxgl, and rewrite
// any mapbox:// style to the keyless URL inside a patched Map constructor.
var KEYLESS_STYLE = 'https://tiles.openfreemap.org/styles/positron';
var lib = document.createElement('link'); lib.rel='stylesheet'; lib.href='./maplibre-gl.css';
document.head.appendChild(lib);
var ml = document.createElement('script');
ml.src = './maplibre-gl.js';
ml.onload = function () {
var Real = window.maplibregl.Map;
function PatchedMap (opts) {
if (opts && typeof opts.style === 'string' && opts.style.indexOf('mapbox://') === 0) opts.style = KEYLESS_STYLE;
return new Real(opts);
}
PatchedMap.prototype = Real.prototype;
window.mapboxgl = Object.assign({}, window.maplibregl, { Map: PatchedMap, accessToken: '' });
loadLegacy();
};
ml.onerror = function () { loadLegacy(); };
document.head.appendChild(ml);
function loadLegacy () {
var s = document.createElement('script');
s.src = './upg-neighbor.iife.js';
s.onload = function () {
var node = document.createElement(el);
node.style.cssText = 'display:block;width:100%;height:100%';
// The legacy elements use the SAME profile-config attribute contract as the modern
// bundles (measured: without it the element renders its own usage banner).
node.setAttribute('profile-config', JSON.stringify({ profile: el, tk: tk }));
document.getElementById('host').appendChild(node);
};
s.onerror = function () { document.body.innerHTML = '<p style="font:14px system-ui;padding:20px">legacy bundle failed to load</p>'; };
document.head.appendChild(s);
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1086,7 +1086,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "doxa-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q",
"integrity": "sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz",
"elements": [
"upg-neighbor"
],
@ -1094,25 +1094,24 @@
{
"profile": "upg-neighbor",
"file": "upg-neighbor.vue",
"description": "Rescues the five pre-bundler experimental maps that live inside the legacy",
"description": "Who goes to whom: reached neighbors matched to unreached people groups, five lenses",
"element": "upg-neighbor",
"type": "parameterized",
"isMap": false,
"type": "monolithic",
"isMap": true,
"iframeUrl": "./upg-neighbor/upg-neighbor/index.html",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{
"id": "send-map",
"title": "Send Map (legacy)",
"title": "Send Map",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "send-map",
"legacyTitle": "Send Map"
"view": "send-map"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/send-map/index.html",
@ -1122,12 +1121,11 @@
},
{
"id": "upg-neighbors",
"title": "UPG Neighbors (legacy)",
"title": "UPG Neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "upg-neighbors",
"legacyTitle": "UPG Neighbors"
"view": "cockpit"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/upg-neighbors/index.html",
@ -1137,12 +1135,11 @@
},
{
"id": "lang-cluster",
"title": "Language Clusters (legacy)",
"title": "Language Clusters",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "lang-cluster",
"legacyTitle": "Language Clusters"
"view": "lang-cluster"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/lang-cluster/index.html",
@ -1152,12 +1149,11 @@
},
{
"id": "bloom-cockpit",
"title": "Bloom Cockpit (legacy)",
"title": "Bloom Cockpit",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "bloom-cockpit",
"legacyTitle": "Bloom Cockpit"
"view": "cockpit"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/bloom-cockpit/index.html",
@ -1167,12 +1163,11 @@
},
{
"id": "flower-mesh",
"title": "Flower Mesh (legacy)",
"title": "Flower Mesh",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "flower-mesh",
"legacyTitle": "Flower Mesh"
"view": "flower-mesh"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/flower-mesh/index.html",
@ -1180,7 +1175,8 @@
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/flower-mesh/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
}
]
],
"bundle": "upg-neighbor"
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bloom Cockpit (legacy) · upg-neighbor</title>
<title>Bloom Cockpit · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"bloom-cockpit","legacyTitle":"Bloom Cockpit"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"cockpit"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flower Mesh (legacy) · upg-neighbor</title>
<title>Flower Mesh · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"flower-mesh","legacyTitle":"Flower Mesh"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"flower-mesh"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upg-neighbor · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +48,7 @@
var BASE_CONFIG = {"profile":"upg-neighbor","dataSource":"pray-tools","instanceId":"upg-neighbor-upg-neighbor","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Language Clusters (legacy) · upg-neighbor</title>
<title>Language Clusters · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"lang-cluster","legacyTitle":"Language Clusters"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"lang-cluster"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Send Map (legacy) · upg-neighbor</title>
<title>Send Map · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"send-map","legacyTitle":"Send Map"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"send-map"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UPG Neighbors (legacy) · upg-neighbor</title>
<title>UPG Neighbors · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"upg-neighbors","legacyTitle":"UPG Neighbors"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"cockpit"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts