Merge main into go-int/work

# Conflicts:
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-internet/board/board/index.html
#	public/dx-maps/gospel-internet/board/index.html
#	public/dx-maps/gospel-internet/board/india-china/index.html
#	public/dx-maps/gospel-internet/board/language-tree/index.html
#	public/dx-maps/gospel-internet/board/platforms/index.html
#	public/dx-maps/gospel-internet/board/project-700/index.html
#	public/dx-maps/gospel-internet/board/zero/index.html
#	public/dx-maps/gospel-internet/gospel-internet.js
#	public/dx-maps/manifest.json
This commit is contained in:
Doxa Maps 2026-09-27 06:55:57 +00:00
commit 2ff6148034
104 changed files with 2821 additions and 42872 deletions

File diff suppressed because one or more lines are too long

View file

@ -74,6 +74,7 @@ for (const b of manifest.bundles || []) {
if ((b.profiles || []).some((p) => p.isMap)) mapBundles.add(b.bundle)
}
const targets = []
const mapless = [] // composites that nest no map: reported as N/A, never as unmeasured
for (const b of manifest.bundles || []) {
// TWO WAYS A COMPOSITE DECLARES ITSELF, and taking only the first under-covered this gate.
// `composes` is written when a composite mounts children BY TAG; a composite that BAKES its
@ -84,8 +85,24 @@ for (const b of manifest.bundles || []) {
const byCompose = composes.some((c) => mapBundles.has(c))
const profile = (b.profiles || []).find((p) => p.element && (byCompose || p.type === 'nested-elements-mfe'))
if (!profile || !b.scriptUrl) continue
// ★ A COMPOSITE WITH NO MAP IS NOT A MAP-EMBED TARGET, and counting it as one buys a permanent
// UNMEASURED that reads like a gap. The `type` arm above is deliberately loose — country-posters-mfe
// BAKES its children, so `composes` names nothing — but loose enough to admit composites that nest no
// map at all: bp-big-picture composes a research HEADER and a people-groups GRID, and its built
// bundle contains zero occurrences of mapbox-gl or maplibre-gl. It can never draw a canvas, so
// "no canvas under a minimal config" was the honest answer to a question that should not have been
// asked of it. Decided on the BUILT artefact rather than the manifest, because whether a bundle
// ships an engine is a fact about what it compiled, not about what it declares.
if (!byCompose) {
let js = ''
try { js = readFileSync(resolve(ROOT, 'public', TREE_NAME, b.bundle, `${b.bundle}.js`), 'utf8') } catch (_) { js = '' }
if (js && !/mapbox-gl|maplibre-gl/.test(js)) { mapless.push(`${profile.element} (${b.bundle})`); continue }
}
targets.push({ bundle: b.bundle, element: profile.element, profile: profile.profile, script: `${b.bundle}/${b.bundle}.js` })
}
if (mapless.length) {
console.log(` · ${mapless.length} composite(s) nest NO map, so the knobs do not apply and they are not targets: ${mapless.join(', ')}`)
}
if (!targets.length) {
console.log('COMPOSITE-EMBED-KNOBS: SKIP — the manifest declares no composite that nests a map.')
process.exit(0)
@ -156,7 +173,10 @@ try {
// The composite's OWN profile rides with the knob under test (rega, 1a761741: gospel-resources,
// my-upg-100-list, regions-dashboard and two more drew no map under a knob-only config, because a
// multi-profile element needs to be told which profile is the composite). A knob still wins.
const cfg = JSON.stringify({ profile: t.profile, ...c.config(bakedToken) }).replace(/'/g, ''')
// A composite that opens on a non-map view must be told to show its map (exp-shepherd: country-posters-mfe
// reads profile-config `view`), or there is no canvas to measure.
const VIEW = { 'country-posters-mfe': { view: 'map' } }
const cfg = JSON.stringify({ profile: t.profile, ...(VIEW[t.element] || {}), ...c.config(bakedToken) }).replace(/'/g, ''')
pageHtml = `<!doctype html><html><head><meta charset=utf-8><title>partner</title></head><body style="margin:0">
<header style="height:48px;background:#eee">HOST HEADER</header>
<script>window.MAP_APP_API_URL="https://pray.doxa.life";</script>

View file

@ -130,9 +130,9 @@ const NO_LINKS = {
// ── D: late getCurrentInstance() sites already routed to an owner ─────────────────────────────
const LATE_KNOWN = {
// reflectDir runs from watch(textDir): a host switching lang to ar AFTER mount gets no dir=rtl.
[`${PD}/dashboard-plus-grid/dashboard-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
[`${PD}/research-map-plus-grid/research-map-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
// (The two reflectDir entries that sat here are gone: landing 11 captured the instance in setup, so
// a host switching lang to ar after mount does get dir=rtl. This gate refuses a known-issue entry
// that no longer matches — a stale exemption is a defect it would stop reporting.)
}
const fails = []

View file

@ -17,7 +17,7 @@
import { computed, provide, inject, watchEffect, watch } from 'vue'
import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js'
import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js'
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale } from './i18n/index.js'
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, localeFromUrl } from './i18n/index.js'
// QA corner: COMPILE-TIME gated (__DOXA_QA__, vite define). In public builds
// the flag is false → this whole branch (and the widget's code, including its
// platform references) is eliminated from the bundle. Platform builds
@ -158,7 +158,10 @@ const showPinHint = computed(() => config.value?.pinHint !== false)
// shipped catalogs; an unknown/absent value leaves the i18n-detected default
// (document.documentElement.lang → 'en') untouched.
const lang = computed(() => {
const raw = (config.value?.locale || config.value?.lang || '').split('-')[0].toLowerCase()
// ?lang= / ?locale= on the page is the last rung: an embed opened with ?lang=ar already got Arabic TEXT
// (createAppI18n reads the URL) but this component's lang stayed '' so its dir and data language did not
// follow (upg100-plugin). profile-config still wins.
const raw = (config.value?.locale || config.value?.lang || localeFromUrl() || '').split('-')[0].toLowerCase()
return SUPPORTED_LOCALES.includes(raw) ? raw : ''
})
// CRITICAL: this component is the ROOT of a defineCustomElement. vue-i18n's

View file

@ -39,7 +39,7 @@
* CROSS-PANE COMMS — this wall NEVER imports a map. It dispatches a window
* CustomEvent scoped by instanceId, so several embeds on one page never cross-talk.
*/
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { tStatic } from '../../i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
@ -284,6 +284,24 @@ const toggleSection = (name) => {
const lastShown = ref(null)
const missing = ref({})
// FORGET the shown card when the wall's view goes inactive (dx-shepherd, row 79). A composite marks its
// inactive pane `inert` (the PinHint contract); after "Show zone" -> Map -> back, the same card must SHOW
// again, not toggle off a selection the reader can no longer see. Watches the ancestor chain, through
// shadow roots, for inert/hidden/aria-hidden; clears lastShown only (the map keeps its selection).
const wallEl = ref(null)
let paneMOs = []
onMounted(() => {
const chain = []
for (let n = wallEl.value; n; n = n.parentNode || n.host || null) if (n.nodeType === 1) chain.push(n)
const inactive = () => chain.some((a) => a.hasAttribute('inert') || a.hasAttribute('hidden') || a.getAttribute('aria-hidden') === 'true')
if (typeof MutationObserver === 'undefined') return
for (const a of chain) {
const mo = new MutationObserver(() => { if (inactive()) lastShown.value = null })
mo.observe(a, { attributes: true, attributeFilter: ['inert', 'hidden', 'aria-hidden'] })
paneMOs.push(mo)
}
})
onBeforeUnmount(() => { for (const mo of paneMOs) mo.disconnect(); paneMOs = [] })
function showOnMap(item) {
// TOGGLE, not a one-way set: a control the user can turn on but not off reads as
@ -949,7 +967,7 @@ useShadowStyles(
</script>
<template>
<div class="wall" :class="{ 'is-dark': isDark, 'wall--embedded': embedded }">
<div ref="wallEl" class="wall" :class="{ 'is-dark': isDark, 'wall--embedded': embedded }">
<h2 class="wall-h">{{ heading }}</h2>
<div v-if="tag" class="wall-tag">{{ tag }}</div>
<p v-if="intro" class="wall-sub" v-html="intro"></p>

View file

@ -352,8 +352,16 @@ export function useDashboardLayout(options = {}) {
// Read per drag rather than cached: a host can flip dir between gestures (our own composites
// set it on the custom-element host from the runtime locale), and a cached sign would be wrong
// for the rest of the session.
// ★ PROBE THE HANDLE, NOT THE SHELL. The sign has to match the element the GRID lays out with,
// and the handle is a grid child: whatever direction the grid resolves, the handle inherits. The
// shell is one node further out, so a host that sets dir between the two — or a shellRef that is
// not the grid itself — gives a direction that disagrees with the mirroring the user can see.
// Falls back to the shell, then to ltr, so a detached handle cannot throw mid-gesture.
const rtl = (() => {
try { return getComputedStyle(unref(shellRef)).direction === 'rtl' } catch (_) { return false }
for (const node of [e.currentTarget, unref(shellRef)]) {
try { const d = node && getComputedStyle(node).direction; if (d) return d === 'rtl' } catch (_) { /* next */ }
}
return false
})()
const xSign = rtl ? -1 : 1
const handleEl = e.currentTarget
@ -549,7 +557,8 @@ export function useDashboardLayout(options = {}) {
// watched. aria-valuenow reports the same ref for both, so a disagreement here would also
// make the announced value move the wrong way.
let kSign = 1
try { if (getComputedStyle(unref(shellRef)).direction === 'rtl') kSign = -1 } catch (_) { /* no element */ }
// Same node choice as the pointer path above, for the same reason.
try { for (const node of [e.currentTarget, unref(shellRef)]) { const d = node && getComputedStyle(node).direction; if (d) { if (d === 'rtl') kSign = -1; break } } } catch (_) { /* no element */ }
if (K === 'ArrowLeft') dir = -1 * kSign; else if (K === 'ArrowRight') dir = 1 * kSign
}
if (dir) { e.preventDefault(); stepResize(axis, dir); return }

View file

@ -6,7 +6,9 @@
"sendMap": "خريطة الإرسال",
"flowerMesh": "زهرة الشبكة",
"langCluster": "عناقيد اللغات",
"flowers": "الزهور"
"flowers": "الزهور",
"mobilize": "التعبئة",
"campaigns": "الحملات"
},
"hints": {
"cockpit": "مركز التصفية المتقاطعة",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} شعبًا غير مُبشَّر · {s} من المُرسِلين الذين تم الوصول إليهم",
"noSenders": "لا يوجد مُرسِلون مؤهّلون تم الوصول إليهم ولهم مركز دولة معروف لهذا الهدف."
},
"about": {
"button": "كيف يعمل",
"title": "تعبئة الجيران القريبين",
"close": "إغلاق",
"why": "المؤمنون الأقدر على الوصول إلى شعب غير مبلَغ هم عادةً الأقرب إليه لغةً وثقافة. تجدهم هذه الأداة لكل مجموعة غير مبلَغة، باستخدام مقياسَي E وP لرالف د. وينتر، لتخطط مَن تعبّئ، وبأي لغة، وأين.",
"eTitle": "مقياس E: المسافة التي يجب أن يعبرها الشاهد",
"pTitle": "مقياس P: المسافة بين شعب وكنيسة",
"dataTitle": "البيانات وحدودها",
"data": "المبلَغ يعني أن نسبة الإنجيليين 2% على الأقل (تعريف IMB). تُقدَّر اللغة والثقافة من رمز اللغة وتصنيف الشعوب لدى IMB (المجموعة، الكتلة، العائلة اللغوية): مؤشرات عامة جيدة، لا تقييمات ميدانية. أعداد المؤمنين تقديرية من السكان وفئات نسب الإنجيليين؛ استخدمها لتقدير حجم الجمهور لا كأعداد دقيقة.",
"source": "بيانات الشعوب: peoplegroups.org (IMB)، people_groups.csv. مقياسا E وP: رالف د. وينتر، مؤتمر لوزان، 1974."
},
"e": {
"short1": "E-1 · اللغة نفسها",
"short2": "E-2 · ثقافة قريبة",
"short3": "E-3 · قريب، ثقافة أخرى",
"title1": "E-1 · يتكلمون اللغة نفسها",
"title2": "E-2 · ثقافة قريبة",
"title3": "E-3 · قريبون، ثقافة مختلفة",
"long1": "مؤمنون يتكلمون لغة هذه المجموعة. يمكنهم مشاركة الإنجيل دون عبور حاجز اللغة، ويمكن لحملة بهذه اللغة أن تعبّئهم.",
"long2": "مؤمنون من شعب قريب: المجموعة الشعبية نفسها، أو العائلة اللغوية نفسها ضمن الكتلة نفسها. خطوة ثقافية أقصر من البدء من الصفر.",
"long3": "مؤمنون يعيشون بالقرب (ضمن 600 كم أو في الدولة نفسها) لكن ثقافتهم مختلفة. يشاركونهم المنطقة لا اللغة."
},
"p": {
"title0": "لم يُعثر على جيران مبلَغين",
"title1": "يوجد مؤمنون بلغته",
"title2": "أقرب المؤمنين من ثقافة قريبة",
"title3": "أقرب المؤمنين من ثقافة بعيدة",
"long0": "لا توجد مجموعة مبلَغة تطابق هذا الشعب لغةً أو ثقافةً أو مسافةً في هذه البيانات.",
"long1": "في مكان ما، تتكلم مجموعة مبلَغة لغة هذا الشعب. تعبئتهم هي الطريق الأكثر مباشرة.",
"long2": "لا توجد مجموعة مبلَغة تتكلم هذه اللغة، لكن لدى شعوب قريبة مؤمنون.",
"long3": "لا يوجد بالقرب إلا مؤمنون من ثقافة مختلفة. الوصول إلى هذا الشعب يحتاج إلى عاملين عبر الثقافات."
},
"res": {
"bible": "الكتاب المقدس",
"film": "فيلم يسوع",
"audio": "صوتيات",
"radio": "راديو"
},
"mob": {
"legend": "مفتاح الخريطة",
"legendUnreached": "مجموعة غير مبلَغة",
"searchPlaceholder": "ابحث عن مجموعة غير مبلَغة أو دولة أو لغة",
"pFilter": "تصفية حسب مستوى P",
"pAll": "الكل",
"loading": "جارٍ تحميل الشعوب…",
"intro": "اختر مجموعة غير مبلَغة (الأكبر أولًا)، أو انقر نقطة حمراء على الخريطة، لترى مَن تعبّئ.",
"noMatch": "لا توجد نتائج.",
"back": "كل المجموعات",
"people": "{n} نسمة",
"believersNear": "نحو {n} مؤمن بين الجيران أدناه.",
"resources": "الموارد بلغته",
"export": "تصدير شرائح الجمهور (CSV)",
"openBridge": "خطّط لحملة بلغة {lang}",
"samePeople": "الشعب نفسه",
"cardStats": "~{b} مؤمن · على بعد {km} كم",
"noNeighbors": "لا يوجد جيران مبلَغون في هذه البيانات.",
"caveat": "تقديرات من بيانات peoplegroups.org. تحقّق من الواقع المحلي قبل التصرف.",
"loadError": "تعذّر تحميل بيانات الشعوب: {message}"
},
"camp": {
"intro": "لغات الجسر: لغات يتكلمها مؤمنون وشعوب غير مبلَغة. حملة واحدة بهذه اللغة، موجهة إلى حيث يعيش المؤمنون، يمكنها أن تعبّئهم للوصول إلى غير المبلَغين بلغتهم.",
"searchPlaceholder": "ابحث عن لغة أو دولة",
"minBelievers": "على الأقل",
"exportAll": "تصدير القائمة (CSV)",
"unreachedPeople": "{n} غير مبلَغ",
"believers": "~{n} مؤمن",
"believersIn": "مؤمنون في: {list}",
"back": "كل اللغات",
"bigBelievers": "مؤمن للتعبئة ({g} مجموعة، {c} دولة)",
"bigUnreached": "غير مبلَغ للوصول إليهم ({g} مجموعة، {c} دولة)",
"export": "تصدير هذه الحملة (CSV)",
"whereToRun": "أين تُطلقها: المؤمنون حسب الدولة",
"believersInGroups": "~{n} في {g} مجموعة",
"whoTheyReach": "مَن يمكنهم الوصول إليه"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Sendekarte",
"flowerMesh": "Blütennetz",
"langCluster": "Sprachcluster",
"flowers": "Blüten"
"flowers": "Blüten",
"mobilize": "Mobilisieren",
"campaigns": "Kampagnen"
},
"hints": {
"cockpit": "Crossfilter-Zentrale",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} UPGs · {s} erreichte Sender",
"noSenders": "Keine geeigneten erreichten Sender mit bekanntem Länderschwerpunkt für dieses Ziel."
},
"about": {
"button": "So funktioniert es",
"title": "Nahe Nachbarn mobilisieren",
"close": "Schließen",
"why": "Die Gläubigen, die ein unerreichtes Volk am besten erreichen können, stehen ihm meist in Sprache und Kultur am nächsten. Dieses Werkzeug findet sie für jede unerreichte Volksgruppe mit Ralph D. Winters E-Skala und P-Skala, damit Sie planen können, wen Sie mobilisieren, in welcher Sprache und wo.",
"eTitle": "E-Skala: die Distanz, die ein Zeuge überwinden muss",
"pTitle": "P-Skala: die Distanz zwischen einem Volk und einer Gemeinde",
"dataTitle": "Daten und Grenzen",
"data": "Erreicht heißt mindestens 2 % Evangelikale (IMB-Definition). Sprache und Kultur werden aus dem Sprachcode und der IMB-Völkertaxonomie (Cluster, Affinitätsblock, Sprachfamilie) abgeleitet: gute öffentliche Näherungswerte, keine Feldbewertungen. Die Zahl der Gläubigen ist aus Bevölkerung und Evangelikalen-Spannen geschätzt; sie dient zur Größenabschätzung eines Publikums, nicht als Zählung.",
"source": "Volksgruppendaten: peoplegroups.org (IMB), people_groups.csv. E-Skala und P-Skala: Ralph D. Winter, Lausanner Kongress, 1974."
},
"e": {
"short1": "E-1 · gleiche Sprache",
"short2": "E-2 · nahe Kultur",
"short3": "E-3 · in der Nähe, andere Kultur",
"title1": "E-1 · Sie sprechen dieselbe Sprache",
"title2": "E-2 · Eine nahe Kultur",
"title3": "E-3 · In der Nähe, eine andere Kultur",
"long1": "Gläubige, die die Sprache dieser Gruppe sprechen. Sie können das Evangelium ohne Sprachbarriere weitergeben, und eine Kampagne in dieser Sprache kann sie mobilisieren.",
"long2": "Gläubige aus einem verwandten Volk: gleiches Völkercluster oder gleiche Sprachfamilie im selben Affinitätsblock. Ein kürzerer kultureller Schritt als ein Neuanfang.",
"long3": "Gläubige in der Nähe (im Umkreis von 600 km oder im selben Land) mit anderer Kultur. Sie teilen die Region, nicht die Sprache."
},
"p": {
"title0": "Keine erreichten Nachbarn gefunden",
"title1": "Es gibt Gläubige in seiner Sprache",
"title2": "Die nächsten Gläubigen sind eine nahe Kultur",
"title3": "Die nächsten Gläubigen sind eine ferne Kultur",
"long0": "In diesen Daten passt keine erreichte Gruppe nach Sprache, Kultur oder Entfernung zu diesem Volk.",
"long1": "Irgendwo spricht eine erreichte Gruppe die Sprache dieses Volkes. Sie zu mobilisieren ist der direkteste Weg.",
"long2": "Keine erreichte Gruppe spricht diese Sprache, aber verwandte Völker haben Gläubige.",
"long3": "In der Nähe gibt es nur Gläubige anderer Kultur. Dieses Volk zu erreichen braucht interkulturelle Mitarbeiter."
},
"res": {
"bible": "Bibel",
"film": "JESUS-Film",
"audio": "Audio",
"radio": "Radio"
},
"mob": {
"legend": "Kartenlegende",
"legendUnreached": "Unerreichte Gruppe",
"searchPlaceholder": "Unerreichte Gruppe, Land oder Sprache suchen",
"pFilter": "Nach P-Stufe filtern",
"pAll": "Alle",
"loading": "Volksgruppen werden geladen…",
"intro": "Wählen Sie eine unerreichte Gruppe (die größten zuerst) oder klicken Sie auf einen roten Punkt, um zu sehen, wen Sie mobilisieren können.",
"noMatch": "Keine Treffer.",
"back": "Alle Gruppen",
"people": "{n} Menschen",
"believersNear": "Etwa {n} Gläubige unter den Nachbarn unten.",
"resources": "Ressourcen in seiner Sprache",
"export": "Zielgruppensegmente exportieren (CSV)",
"openBridge": "Kampagne auf {lang} planen",
"samePeople": "gleiches Volk",
"cardStats": "~{b} Gläubige · {km} km entfernt",
"noNeighbors": "Keine erreichten Nachbarn in diesen Daten.",
"caveat": "Schätzungen aus Daten von peoplegroups.org. Prüfen Sie die Lage vor Ort, bevor Sie handeln.",
"loadError": "Die Volksgruppendaten konnten nicht geladen werden: {message}"
},
"camp": {
"intro": "Brückensprachen: Sprachen, die sowohl Gläubige als auch unerreichte Völker sprechen. Eine Kampagne in dieser Sprache, dort ausgespielt, wo die Gläubigen leben, kann sie mobilisieren, die Unerreichten in ihrer eigenen Sprache zu erreichen.",
"searchPlaceholder": "Sprache oder Land suchen",
"minBelievers": "Mindestens",
"exportAll": "Liste exportieren (CSV)",
"unreachedPeople": "{n} unerreicht",
"believers": "~{n} Gläubige",
"believersIn": "Gläubige in: {list}",
"back": "Alle Sprachen",
"bigBelievers": "Gläubige zum Mobilisieren ({g} Gruppen, {c} Länder)",
"bigUnreached": "Unerreichte zum Erreichen ({g} Gruppen, {c} Länder)",
"export": "Diese Kampagne exportieren (CSV)",
"whereToRun": "Wo sie laufen soll: Gläubige nach Land",
"believersInGroups": "~{n} in {g} Gruppen",
"whoTheyReach": "Wen sie erreichen können"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Send Map",
"flowerMesh": "Flower Mesh",
"langCluster": "Language Clusters",
"flowers": "Flowers"
"flowers": "Flowers",
"mobilize": "Mobilize",
"campaigns": "Campaigns"
},
"hints": {
"cockpit": "crossfilter hub",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} UPGs · {s} reached senders",
"noSenders": "No qualifying reached senders with a known country centroid for this target."
},
"about": {
"button": "How it works",
"title": "Mobilizing near neighbors",
"close": "Close",
"why": "The believers best placed to reach an unreached people are usually the ones nearest to it in language and culture. This tool finds them for every unreached group, using Ralph D. Winter's E-scale and P-scale, so you can plan whom to mobilize, in which language, and where.",
"eTitle": "E-scale: the distance a witness must cross",
"pTitle": "P-scale: the distance between a people and a church",
"dataTitle": "Data and limits",
"data": "Reached means at least 2% evangelical (the IMB definition). Language and culture are judged from the language code and the IMB people taxonomy (cluster, affinity bloc, language family): good public proxies, not field assessments. Believer numbers are estimates from population and evangelical bands; use them to size an audience, not as counts.",
"source": "People group data: peoplegroups.org (IMB), people_groups.csv. E-scale and P-scale: Ralph D. Winter, Lausanne Congress, 1974."
},
"e": {
"short1": "E-1 · same language",
"short2": "E-2 · near culture",
"short3": "E-3 · nearby, other culture",
"title1": "E-1 · They speak the same language",
"title2": "E-2 · A near culture",
"title3": "E-3 · Nearby, a different culture",
"long1": "Believers who speak this group's language. They can share the gospel without crossing a language barrier, and a campaign in this language can mobilize them.",
"long2": "Believers from a related people: the same people cluster, or the same language family in the same affinity bloc. A shorter cultural step than starting from scratch.",
"long3": "Believers living nearby (within 600 km or in the same country) whose culture is different. They share the region, not the language."
},
"p": {
"title0": "No reached neighbors found",
"title1": "Believers in its own language exist",
"title2": "The nearest believers are a near culture",
"title3": "The nearest believers are a distant culture",
"long0": "No reached group matched this people by language, culture or distance in this data.",
"long1": "Somewhere, a reached group speaks this people's language. Mobilizing them is the most direct path.",
"long2": "No reached group speaks this language, but related peoples have believers.",
"long3": "Only believers of a different culture are nearby. Reaching this people needs cross-cultural workers."
},
"res": {
"bible": "Bible",
"film": "JESUS Film",
"audio": "Audio",
"radio": "Radio"
},
"mob": {
"legend": "Map legend",
"legendUnreached": "Unreached group",
"searchPlaceholder": "Search an unreached group, country or language",
"pFilter": "Filter by P-level",
"pAll": "All",
"loading": "Loading people groups…",
"intro": "Pick an unreached group (largest first), or click a red dot on the map, to see whom to mobilize.",
"noMatch": "Nothing matches.",
"back": "All groups",
"people": "{n} people",
"believersNear": "About {n} believers among the neighbors below.",
"resources": "Resources in its language",
"export": "Export audience segments (CSV)",
"openBridge": "Plan a {lang} campaign",
"samePeople": "same people",
"cardStats": "~{b} believers · {km} km away",
"noNeighbors": "No reached neighbors in this data.",
"caveat": "Estimates from peoplegroups.org data. Check local realities before acting.",
"loadError": "Could not load the people-group data: {message}"
},
"camp": {
"intro": "Bridge languages: languages spoken by believers and by unreached peoples. One campaign in the language, aimed where the believers live, can mobilize them to reach the unreached in their own language.",
"searchPlaceholder": "Search a language or country",
"minBelievers": "At least",
"exportAll": "Export list (CSV)",
"unreachedPeople": "{n} unreached",
"believers": "~{n} believers",
"believersIn": "Believers in: {list}",
"back": "All languages",
"bigBelievers": "believers to mobilize ({g} groups, {c} countries)",
"bigUnreached": "unreached people to reach ({g} groups, {c} countries)",
"export": "Export this campaign (CSV)",
"whereToRun": "Where to run it: believers by country",
"believersInGroups": "~{n} in {g} groups",
"whoTheyReach": "Whom they can reach"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Mapa de envío",
"flowerMesh": "Malla floral",
"langCluster": "Grupos lingüísticos",
"flowers": "Flores"
"flowers": "Flores",
"mobilize": "Movilizar",
"campaigns": "Campañas"
},
"hints": {
"cockpit": "centro de filtros cruzados",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} PNA · {s} enviadores alcanzados",
"noSenders": "No hay enviadores alcanzados que califiquen con un centroide de país conocido para este objetivo."
},
"about": {
"button": "Cómo funciona",
"title": "Movilizar a los vecinos cercanos",
"close": "Cerrar",
"why": "Los creyentes mejor situados para alcanzar a un pueblo no alcanzado suelen ser los más cercanos a él en idioma y cultura. Esta herramienta los encuentra para cada grupo no alcanzado, con la escala E y la escala P de Ralph D. Winter, para que puedas planificar a quién movilizar, en qué idioma y dónde.",
"eTitle": "Escala E: la distancia que debe cruzar un testigo",
"pTitle": "Escala P: la distancia entre un pueblo y una iglesia",
"dataTitle": "Datos y límites",
"data": "Alcanzado significa al menos un 2 % de evangélicos (definición del IMB). El idioma y la cultura se deducen del código de idioma y de la taxonomía de pueblos del IMB (clúster, bloque de afinidad, familia lingüística): buenas aproximaciones públicas, no evaluaciones de campo. El número de creyentes se estima a partir de la población y los rangos de evangélicos; sirve para dimensionar una audiencia, no como recuento.",
"source": "Datos de pueblos: peoplegroups.org (IMB), people_groups.csv. Escala E y escala P: Ralph D. Winter, Congreso de Lausana, 1974."
},
"e": {
"short1": "E-1 · mismo idioma",
"short2": "E-2 · cultura cercana",
"short3": "E-3 · cerca, otra cultura",
"title1": "E-1 · Hablan el mismo idioma",
"title2": "E-2 · Una cultura cercana",
"title3": "E-3 · Cerca, otra cultura",
"long1": "Creyentes que hablan el idioma de este grupo. Pueden compartir el evangelio sin cruzar una barrera lingüística, y una campaña en este idioma puede movilizarlos.",
"long2": "Creyentes de un pueblo emparentado: el mismo clúster de pueblos, o la misma familia lingüística en el mismo bloque de afinidad. Un paso cultural más corto que empezar de cero.",
"long3": "Creyentes que viven cerca (a menos de 600 km o en el mismo país) con una cultura distinta. Comparten la región, no el idioma."
},
"p": {
"title0": "No se encontraron vecinos alcanzados",
"title1": "Existen creyentes en su idioma",
"title2": "Los creyentes más cercanos son de una cultura cercana",
"title3": "Los creyentes más cercanos son de una cultura lejana",
"long0": "En estos datos ningún grupo alcanzado coincide con este pueblo por idioma, cultura o distancia.",
"long1": "En algún lugar, un grupo alcanzado habla el idioma de este pueblo. Movilizarlo es el camino más directo.",
"long2": "Ningún grupo alcanzado habla este idioma, pero pueblos emparentados tienen creyentes.",
"long3": "Cerca solo hay creyentes de otra cultura. Alcanzar a este pueblo requiere obreros transculturales."
},
"res": {
"bible": "Biblia",
"film": "Película JESÚS",
"audio": "Audio",
"radio": "Radio"
},
"mob": {
"legend": "Leyenda del mapa",
"legendUnreached": "Grupo no alcanzado",
"searchPlaceholder": "Busca un grupo no alcanzado, país o idioma",
"pFilter": "Filtrar por nivel P",
"pAll": "Todos",
"loading": "Cargando pueblos…",
"intro": "Elige un grupo no alcanzado (los más grandes primero) o haz clic en un punto rojo del mapa para ver a quién movilizar.",
"noMatch": "Sin resultados.",
"back": "Todos los grupos",
"people": "{n} personas",
"believersNear": "Unos {n} creyentes entre los vecinos de abajo.",
"resources": "Recursos en su idioma",
"export": "Exportar segmentos de audiencia (CSV)",
"openBridge": "Planificar una campaña en {lang}",
"samePeople": "mismo pueblo",
"cardStats": "~{b} creyentes · a {km} km",
"noNeighbors": "No hay vecinos alcanzados en estos datos.",
"caveat": "Estimaciones a partir de datos de peoplegroups.org. Comprueba la realidad local antes de actuar.",
"loadError": "No se pudieron cargar los datos de pueblos: {message}"
},
"camp": {
"intro": "Idiomas puente: idiomas que hablan creyentes y pueblos no alcanzados. Una campaña en ese idioma, dirigida a donde viven los creyentes, puede movilizarlos para alcanzar a los no alcanzados en su propio idioma.",
"searchPlaceholder": "Busca un idioma o país",
"minBelievers": "Al menos",
"exportAll": "Exportar lista (CSV)",
"unreachedPeople": "{n} no alcanzados",
"believers": "~{n} creyentes",
"believersIn": "Creyentes en: {list}",
"back": "Todos los idiomas",
"bigBelievers": "creyentes que movilizar ({g} grupos, {c} países)",
"bigUnreached": "no alcanzados a los que llegar ({g} grupos, {c} países)",
"export": "Exportar esta campaña (CSV)",
"whereToRun": "Dónde lanzarla: creyentes por país",
"believersInGroups": "~{n} en {g} grupos",
"whoTheyReach": "A quién pueden alcanzar"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Carte d’envoi",
"flowerMesh": "Maillage floral",
"langCluster": "Groupes linguistiques",
"flowers": "Fleurs"
"flowers": "Fleurs",
"mobilize": "Mobiliser",
"campaigns": "Campagnes"
},
"hints": {
"cockpit": "centre de filtrage croisé",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} PNA · {s} envoyeurs atteints",
"noSenders": "Aucun envoyeur atteint admissible avec un centroïde de pays connu pour cette cible."
},
"about": {
"button": "Comment ça marche",
"title": "Mobiliser les proches voisins",
"close": "Fermer",
"why": "Les croyants les mieux placés pour atteindre un peuple non atteint sont généralement les plus proches de lui par la langue et la culture. Cet outil les trouve pour chaque groupe non atteint, à l'aide de l'échelle E et de l'échelle P de Ralph D. Winter, pour planifier qui mobiliser, dans quelle langue et où.",
"eTitle": "Échelle E : la distance qu'un témoin doit franchir",
"pTitle": "Échelle P : la distance entre un peuple et une Église",
"dataTitle": "Données et limites",
"data": "Atteint signifie au moins 2 % d'évangéliques (définition de l'IMB). La langue et la culture sont déduites du code de langue et de la taxonomie des peuples de l'IMB (grappe, bloc d'affinité, famille de langues) : de bons indicateurs publics, pas des évaluations de terrain. Le nombre de croyants est estimé à partir de la population et des tranches d'évangéliques ; il sert à dimensionner une audience, pas à compter.",
"source": "Données sur les peuples : peoplegroups.org (IMB), people_groups.csv. Échelles E et P : Ralph D. Winter, Congrès de Lausanne, 1974."
},
"e": {
"short1": "E-1 · même langue",
"short2": "E-2 · culture proche",
"short3": "E-3 · proche, autre culture",
"title1": "E-1 · Ils parlent la même langue",
"title2": "E-2 · Une culture proche",
"title3": "E-3 · Proches, une autre culture",
"long1": "Des croyants qui parlent la langue de ce groupe. Ils peuvent partager l'Évangile sans barrière de langue, et une campagne dans cette langue peut les mobiliser.",
"long2": "Des croyants d'un peuple apparenté : même grappe de peuples, ou même famille de langues dans le même bloc d'affinité. Un pas culturel plus court que de partir de zéro.",
"long3": "Des croyants vivant à proximité (à moins de 600 km ou dans le même pays) mais d'une autre culture. Ils partagent la région, pas la langue."
},
"p": {
"title0": "Aucun voisin atteint trouvé",
"title1": "Il existe des croyants dans sa langue",
"title2": "Les croyants les plus proches sont une culture proche",
"title3": "Les croyants les plus proches sont une culture lointaine",
"long0": "Dans ces données, aucun groupe atteint ne correspond à ce peuple par la langue, la culture ou la distance.",
"long1": "Quelque part, un groupe atteint parle la langue de ce peuple. Le mobiliser est la voie la plus directe.",
"long2": "Aucun groupe atteint ne parle cette langue, mais des peuples apparentés ont des croyants.",
"long3": "À proximité, il n'y a que des croyants d'une autre culture. Atteindre ce peuple demande des ouvriers interculturels."
},
"res": {
"bible": "Bible",
"film": "Film JÉSUS",
"audio": "Audio",
"radio": "Radio"
},
"mob": {
"legend": "Légende de la carte",
"legendUnreached": "Groupe non atteint",
"searchPlaceholder": "Rechercher un groupe non atteint, un pays ou une langue",
"pFilter": "Filtrer par niveau P",
"pAll": "Tous",
"loading": "Chargement des peuples…",
"intro": "Choisissez un groupe non atteint (les plus grands d’abord) ou cliquez sur un point rouge pour voir qui mobiliser.",
"noMatch": "Aucun résultat.",
"back": "Tous les groupes",
"people": "{n} personnes",
"believersNear": "Environ {n} croyants parmi les voisins ci-dessous.",
"resources": "Ressources dans sa langue",
"export": "Exporter les segments d'audience (CSV)",
"openBridge": "Planifier une campagne en {lang}",
"samePeople": "même peuple",
"cardStats": "~{b} croyants · à {km} km",
"noNeighbors": "Aucun voisin atteint dans ces données.",
"caveat": "Estimations tirées des données de peoplegroups.org. Vérifiez les réalités locales avant d'agir.",
"loadError": "Impossible de charger les données des peuples : {message}"
},
"camp": {
"intro": "Langues-ponts : des langues parlées par des croyants et par des peuples non atteints. Une campagne dans cette langue, diffusée là où vivent les croyants, peut les mobiliser pour atteindre les non-atteints dans leur propre langue.",
"searchPlaceholder": "Rechercher une langue ou un pays",
"minBelievers": "Au moins",
"exportAll": "Exporter la liste (CSV)",
"unreachedPeople": "{n} non atteints",
"believers": "~{n} croyants",
"believersIn": "Croyants en : {list}",
"back": "Toutes les langues",
"bigBelievers": "croyants à mobiliser ({g} groupes, {c} pays)",
"bigUnreached": "non-atteints à atteindre ({g} groupes, {c} pays)",
"export": "Exporter cette campagne (CSV)",
"whereToRun": "Où la diffuser : croyants par pays",
"believersInGroups": "~{n} dans {g} groupes",
"whoTheyReach": "Qui ils peuvent atteindre"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "भेजने का नक्शा",
"flowerMesh": "फूल-जाल",
"langCluster": "भाषा समूह",
"flowers": "फूल"
"flowers": "फूल",
"mobilize": "संगठित करें",
"campaigns": "अभियान"
},
"hints": {
"cockpit": "क्रॉस-फ़िल्टर केंद्र",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} UPG · {s} पहुँचे हुए भेजने वाले",
"noSenders": "इस लक्ष्य के लिए ज्ञात देश-केंद्र वाला कोई योग्य पहुँचा हुआ भेजने वाला नहीं है।"
},
"about": {
"button": "यह कैसे काम करता है",
"title": "निकट पड़ोसियों को संगठित करना",
"close": "बंद करें",
"why": "किसी अपहुँच जनसमूह तक पहुँचने के लिए सबसे उपयुक्त विश्वासी आम तौर पर वे होते हैं जो भाषा और संस्कृति में उसके सबसे निकट हैं। यह उपकरण राल्फ डी. विंटर के E-स्केल और P-स्केल से हर अपहुँच समूह के लिए उन्हें ढूँढता है, ताकि आप योजना बना सकें कि किसे, किस भाषा में और कहाँ संगठित करना है।",
"eTitle": "E-स्केल: गवाह को कितनी दूरी पार करनी है",
"pTitle": "P-स्केल: किसी जनसमूह और कलीसिया के बीच की दूरी",
"dataTitle": "डेटा और सीमाएँ",
"data": "पहुँचा हुआ का अर्थ है कम से कम 2% इवैंजेलिकल (IMB की परिभाषा)। भाषा और संस्कृति का अनुमान भाषा कोड और IMB के जनसमूह वर्गीकरण (क्लस्टर, सम्बद्धता खंड, भाषा परिवार) से लगाया गया है: अच्छे सार्वजनिक संकेतक, क्षेत्रीय आकलन नहीं। विश्वासियों की संख्या जनसंख्या और इवैंजेलिकल श्रेणियों से अनुमानित है; इसे दर्शक-संख्या के अनुमान के लिए लें, गिनती के रूप में नहीं।",
"source": "जनसमूह डेटा: peoplegroups.org (IMB), people_groups.csv. E-स्केल और P-स्केल: राल्फ डी. विंटर, लॉज़ेन कांग्रेस, 1974."
},
"e": {
"short1": "E-1 · वही भाषा",
"short2": "E-2 · निकट संस्कृति",
"short3": "E-3 · पास में, अन्य संस्कृति",
"title1": "E-1 · वे वही भाषा बोलते हैं",
"title2": "E-2 · एक निकट संस्कृति",
"title3": "E-3 · पास में, भिन्न संस्कृति",
"long1": "वे विश्वासी जो इस समूह की भाषा बोलते हैं। वे भाषा की बाधा पार किए बिना सुसमाचार बाँट सकते हैं, और इस भाषा में एक अभियान उन्हें संगठित कर सकता है।",
"long2": "संबंधित जनसमूह के विश्वासी: वही जनसमूह क्लस्टर, या उसी सम्बद्धता खंड में वही भाषा परिवार। शून्य से शुरू करने की तुलना में छोटा सांस्कृतिक कदम।",
"long3": "पास में रहने वाले विश्वासी (600 किमी के भीतर या उसी देश में) जिनकी संस्कृति भिन्न है। वे क्षेत्र साझा करते हैं, भाषा नहीं।"
},
"p": {
"title0": "कोई पहुँचा हुआ पड़ोसी नहीं मिला",
"title1": "इसकी अपनी भाषा में विश्वासी हैं",
"title2": "निकटतम विश्वासी एक निकट संस्कृति के हैं",
"title3": "निकटतम विश्वासी एक दूर की संस्कृति के हैं",
"long0": "इस डेटा में कोई पहुँचा हुआ समूह भाषा, संस्कृति या दूरी से इस जनसमूह से मेल नहीं खाता।",
"long1": "कहीं न कहीं कोई पहुँचा हुआ समूह इस जनसमूह की भाषा बोलता है। उन्हें संगठित करना सबसे सीधा रास्ता है।",
"long2": "कोई पहुँचा हुआ समूह यह भाषा नहीं बोलता, पर संबंधित जनसमूहों में विश्वासी हैं।",
"long3": "पास में केवल भिन्न संस्कृति के विश्वासी हैं। इस जनसमूह तक पहुँचने के लिए अंतर-सांस्कृतिक कार्यकर्ताओं की ज़रूरत है।"
},
"res": {
"bible": "बाइबल",
"film": "यीशु फ़िल्म",
"audio": "ऑडियो",
"radio": "रेडियो"
},
"mob": {
"legend": "मानचित्र सूचकांक",
"legendUnreached": "अपहुँच समूह",
"searchPlaceholder": "अपहुँच समूह, देश या भाषा खोजें",
"pFilter": "P-स्तर से छाँटें",
"pAll": "सभी",
"loading": "जनसमूह लोड हो रहे हैं…",
"intro": "देखें किसे संगठित करना है: कोई अपहुँच समूह चुनें (सबसे बड़े पहले) या मानचित्र पर किसी लाल बिंदु पर क्लिक करें।",
"noMatch": "कोई परिणाम नहीं।",
"back": "सभी समूह",
"people": "{n} लोग",
"believersNear": "नीचे दिए पड़ोसियों में लगभग {n} विश्वासी।",
"resources": "इसकी भाषा में संसाधन",
"export": "दर्शक खंड निर्यात करें (CSV)",
"openBridge": "{lang} में अभियान की योजना बनाएँ",
"samePeople": "वही जनसमूह",
"cardStats": "~{b} विश्वासी · {km} किमी दूर",
"noNeighbors": "इस डेटा में कोई पहुँचा हुआ पड़ोसी नहीं।",
"caveat": "peoplegroups.org के डेटा से अनुमान। कार्य करने से पहले स्थानीय वास्तविकता जाँचें।",
"loadError": "जनसमूह डेटा लोड नहीं हो सका: {message}"
},
"camp": {
"intro": "सेतु भाषाएँ: वे भाषाएँ जिन्हें विश्वासी और अपहुँच जनसमूह दोनों बोलते हैं। उस भाषा में एक अभियान, जहाँ विश्वासी रहते हैं वहाँ चलाकर, उन्हें अपहुँच लोगों तक उनकी अपनी भाषा में पहुँचने के लिए संगठित कर सकता है।",
"searchPlaceholder": "भाषा या देश खोजें",
"minBelievers": "कम से कम",
"exportAll": "सूची निर्यात करें (CSV)",
"unreachedPeople": "{n} अपहुँच",
"believers": "~{n} विश्वासी",
"believersIn": "विश्वासी यहाँ: {list}",
"back": "सभी भाषाएँ",
"bigBelievers": "संगठित करने योग्य विश्वासी ({g} समूह, {c} देश)",
"bigUnreached": "पहुँचने योग्य अपहुँच लोग ({g} समूह, {c} देश)",
"export": "यह अभियान निर्यात करें (CSV)",
"whereToRun": "इसे कहाँ चलाएँ: देश के अनुसार विश्वासी",
"believersInGroups": "~{n}, {g} समूहों में",
"whoTheyReach": "वे किन तक पहुँच सकते हैं"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Mappa di invio",
"flowerMesh": "Rete floreale",
"langCluster": "Gruppi linguistici",
"flowers": "Fiori"
"flowers": "Fiori",
"mobilize": "Mobilitare",
"campaigns": "Campagne"
},
"hints": {
"cockpit": "centro dei filtri incrociati",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} PNR · {s} mittenti raggiunti",
"noSenders": "Nessun mittente raggiunto idoneo con un baricentro nazionale noto per questo obiettivo."
},
"about": {
"button": "Come funziona",
"title": "Mobilitare i vicini prossimi",
"close": "Chiudi",
"why": "I credenti più adatti a raggiungere un popolo non raggiunto sono di solito i più vicini a esso per lingua e cultura. Questo strumento li trova per ogni gruppo non raggiunto, con la scala E e la scala P di Ralph D. Winter, così puoi pianificare chi mobilitare, in quale lingua e dove.",
"eTitle": "Scala E: la distanza che un testimone deve attraversare",
"pTitle": "Scala P: la distanza tra un popolo e una chiesa",
"dataTitle": "Dati e limiti",
"data": "Raggiunto significa almeno il 2% di evangelici (definizione IMB). Lingua e cultura sono dedotte dal codice di lingua e dalla tassonomia dei popoli dell'IMB (cluster, blocco di affinità, famiglia linguistica): buone approssimazioni pubbliche, non valutazioni sul campo. Il numero di credenti è stimato da popolazione e fasce di evangelici; serve a dimensionare un pubblico, non come conteggio.",
"source": "Dati sui popoli: peoplegroups.org (IMB), people_groups.csv. Scala E e scala P: Ralph D. Winter, Congresso di Losanna, 1974."
},
"e": {
"short1": "E-1 · stessa lingua",
"short2": "E-2 · cultura vicina",
"short3": "E-3 · vicino, altra cultura",
"title1": "E-1 · Parlano la stessa lingua",
"title2": "E-2 · Una cultura vicina",
"title3": "E-3 · Vicini, un’altra cultura",
"long1": "Credenti che parlano la lingua di questo gruppo. Possono condividere il Vangelo senza barriere linguistiche, e una campagna in questa lingua può mobilitarli.",
"long2": "Credenti di un popolo affine: lo stesso cluster di popoli, o la stessa famiglia linguistica nello stesso blocco di affinità. Un passo culturale più breve che partire da zero.",
"long3": "Credenti che vivono vicino (entro 600 km o nello stesso paese) ma di cultura diversa. Condividono la regione, non la lingua."
},
"p": {
"title0": "Nessun vicino raggiunto trovato",
"title1": "Esistono credenti nella sua lingua",
"title2": "I credenti più vicini sono di una cultura vicina",
"title3": "I credenti più vicini sono di una cultura lontana",
"long0": "In questi dati nessun gruppo raggiunto corrisponde a questo popolo per lingua, cultura o distanza.",
"long1": "Da qualche parte un gruppo raggiunto parla la lingua di questo popolo. Mobilitarlo è la via più diretta.",
"long2": "Nessun gruppo raggiunto parla questa lingua, ma popoli affini hanno credenti.",
"long3": "Vicino ci sono solo credenti di un'altra cultura. Raggiungere questo popolo richiede operai interculturali."
},
"res": {
"bible": "Bibbia",
"film": "Film JESUS",
"audio": "Audio",
"radio": "Radio"
},
"mob": {
"legend": "Legenda della mappa",
"legendUnreached": "Gruppo non raggiunto",
"searchPlaceholder": "Cerca un gruppo non raggiunto, paese o lingua",
"pFilter": "Filtra per livello P",
"pAll": "Tutti",
"loading": "Caricamento dei popoli…",
"intro": "Scegli un gruppo non raggiunto (prima i più grandi) o clicca un punto rosso sulla mappa per vedere chi mobilitare.",
"noMatch": "Nessun risultato.",
"back": "Tutti i gruppi",
"people": "{n} persone",
"believersNear": "Circa {n} credenti tra i vicini qui sotto.",
"resources": "Risorse nella sua lingua",
"export": "Esporta i segmenti di pubblico (CSV)",
"openBridge": "Pianifica una campagna in {lang}",
"samePeople": "stesso popolo",
"cardStats": "~{b} credenti · a {km} km",
"noNeighbors": "Nessun vicino raggiunto in questi dati.",
"caveat": "Stime dai dati di peoplegroups.org. Verifica la realtà locale prima di agire.",
"loadError": "Impossibile caricare i dati dei popoli: {message}"
},
"camp": {
"intro": "Lingue ponte: lingue parlate da credenti e da popoli non raggiunti. Una campagna in quella lingua, rivolta dove vivono i credenti, può mobilitarli a raggiungere i non raggiunti nella loro lingua.",
"searchPlaceholder": "Cerca una lingua o un paese",
"minBelievers": "Almeno",
"exportAll": "Esporta elenco (CSV)",
"unreachedPeople": "{n} non raggiunti",
"believers": "~{n} credenti",
"believersIn": "Credenti in: {list}",
"back": "Tutte le lingue",
"bigBelievers": "credenti da mobilitare ({g} gruppi, {c} paesi)",
"bigUnreached": "non raggiunti da raggiungere ({g} gruppi, {c} paesi)",
"export": "Esporta questa campagna (CSV)",
"whereToRun": "Dove lanciarla: credenti per paese",
"believersInGroups": "~{n} in {g} gruppi",
"whoTheyReach": "Chi possono raggiungere"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Mapa de envio",
"flowerMesh": "Malha floral",
"langCluster": "Grupos linguísticos",
"flowers": "Flores"
"flowers": "Flores",
"mobilize": "Mobilizar",
"campaigns": "Campanhas"
},
"hints": {
"cockpit": "centro de filtros cruzados",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} PNA · {s} enviadores alcançados",
"noSenders": "Nenhum enviador alcançado qualificado com um centroide de país conhecido para este alvo."
},
"about": {
"button": "Como funciona",
"title": "Mobilizar os vizinhos próximos",
"close": "Fechar",
"why": "Os crentes mais bem posicionados para alcançar um povo não alcançado costumam ser os mais próximos dele em língua e cultura. Esta ferramenta encontra-os para cada grupo não alcançado, com a escala E e a escala P de Ralph D. Winter, para que possa planear quem mobilizar, em que língua e onde.",
"eTitle": "Escala E: a distância que uma testemunha tem de atravessar",
"pTitle": "Escala P: a distância entre um povo e uma igreja",
"dataTitle": "Dados e limites",
"data": "Alcançado significa pelo menos 2% de evangélicos (definição do IMB). A língua e a cultura são deduzidas do código de língua e da taxonomia de povos do IMB (agrupamento, bloco de afinidade, família linguística): boas aproximações públicas, não avaliações de campo. O número de crentes é estimado a partir da população e das faixas de evangélicos; serve para dimensionar uma audiência, não como contagem.",
"source": "Dados de povos: peoplegroups.org (IMB), people_groups.csv. Escala E e escala P: Ralph D. Winter, Congresso de Lausanne, 1974."
},
"e": {
"short1": "E-1 · mesma língua",
"short2": "E-2 · cultura próxima",
"short3": "E-3 · perto, outra cultura",
"title1": "E-1 · Falam a mesma língua",
"title2": "E-2 · Uma cultura próxima",
"title3": "E-3 · Perto, outra cultura",
"long1": "Crentes que falam a língua deste grupo. Podem partilhar o evangelho sem atravessar uma barreira linguística, e uma campanha nesta língua pode mobilizá-los.",
"long2": "Crentes de um povo aparentado: o mesmo agrupamento de povos, ou a mesma família linguística no mesmo bloco de afinidade. Um passo cultural mais curto do que começar do zero.",
"long3": "Crentes que vivem perto (a menos de 600 km ou no mesmo país) mas de cultura diferente. Partilham a região, não a língua."
},
"p": {
"title0": "Nenhum vizinho alcançado encontrado",
"title1": "Existem crentes na sua língua",
"title2": "Os crentes mais próximos são de uma cultura próxima",
"title3": "Os crentes mais próximos são de uma cultura distante",
"long0": "Nestes dados nenhum grupo alcançado corresponde a este povo por língua, cultura ou distância.",
"long1": "Algures, um grupo alcançado fala a língua deste povo. Mobilizá-lo é o caminho mais direto.",
"long2": "Nenhum grupo alcançado fala esta língua, mas povos aparentados têm crentes.",
"long3": "Perto só há crentes de outra cultura. Alcançar este povo exige obreiros transculturais."
},
"res": {
"bible": "Bíblia",
"film": "Filme JESUS",
"audio": "Áudio",
"radio": "Rádio"
},
"mob": {
"legend": "Legenda do mapa",
"legendUnreached": "Grupo não alcançado",
"searchPlaceholder": "Pesquise um grupo não alcançado, país ou língua",
"pFilter": "Filtrar por nível P",
"pAll": "Todos",
"loading": "A carregar povos…",
"intro": "Escolha um grupo não alcançado (os maiores primeiro) ou clique num ponto vermelho no mapa para ver quem mobilizar.",
"noMatch": "Sem resultados.",
"back": "Todos os grupos",
"people": "{n} pessoas",
"believersNear": "Cerca de {n} crentes entre os vizinhos abaixo.",
"resources": "Recursos na sua língua",
"export": "Exportar segmentos de público (CSV)",
"openBridge": "Planear uma campanha em {lang}",
"samePeople": "mesmo povo",
"cardStats": "~{b} crentes · a {km} km",
"noNeighbors": "Nenhum vizinho alcançado nestes dados.",
"caveat": "Estimativas a partir de dados de peoplegroups.org. Verifique a realidade local antes de agir.",
"loadError": "Não foi possível carregar os dados dos povos: {message}"
},
"camp": {
"intro": "Línguas-ponte: línguas faladas por crentes e por povos não alcançados. Uma campanha nessa língua, dirigida onde vivem os crentes, pode mobilizá-los para alcançar os não alcançados na sua própria língua.",
"searchPlaceholder": "Pesquise uma língua ou país",
"minBelievers": "Pelo menos",
"exportAll": "Exportar lista (CSV)",
"unreachedPeople": "{n} não alcançados",
"believers": "~{n} crentes",
"believersIn": "Crentes em: {list}",
"back": "Todas as línguas",
"bigBelievers": "crentes a mobilizar ({g} grupos, {c} países)",
"bigUnreached": "não alcançados a alcançar ({g} grupos, {c} países)",
"export": "Exportar esta campanha (CSV)",
"whereToRun": "Onde lançá-la: crentes por país",
"believersInGroups": "~{n} em {g} grupos",
"whoTheyReach": "Quem podem alcançar"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Harta de trimitere",
"flowerMesh": "Rețea florală",
"langCluster": "Grupuri lingvistice",
"flowers": "Flori"
"flowers": "Flori",
"mobilize": "Mobilizare",
"campaigns": "Campanii"
},
"hints": {
"cockpit": "centru de filtrare încrucișată",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} UPG · {s} trimițători atinși",
"noSenders": "Niciun trimițător atins eligibil cu un centroid de țară cunoscut pentru această țintă."
},
"about": {
"button": "Cum funcționează",
"title": "Mobilizarea vecinilor apropiați",
"close": "Închide",
"why": "Credincioșii cei mai potriviți să ajungă la un popor neatins sunt de obicei cei mai apropiați de el prin limbă și cultură. Acest instrument îi găsește pentru fiecare grup neatins, folosind scala E și scala P ale lui Ralph D. Winter, ca să puteți planifica pe cine să mobilizați, în ce limbă și unde.",
"eTitle": "Scala E: distanța pe care trebuie să o traverseze un martor",
"pTitle": "Scala P: distanța dintre un popor și o biserică",
"dataTitle": "Date și limite",
"data": "Atins înseamnă cel puțin 2% evanghelici (definiția IMB). Limba și cultura sunt deduse din codul limbii și din taxonomia popoarelor IMB (grup, bloc de afinitate, familie de limbi): aproximări publice bune, nu evaluări de teren. Numărul credincioșilor este estimat din populație și intervalele de evanghelici; folosiți-l pentru a dimensiona o audiență, nu ca numărătoare.",
"source": "Date despre popoare: peoplegroups.org (IMB), people_groups.csv. Scala E și scala P: Ralph D. Winter, Congresul de la Lausanne, 1974."
},
"e": {
"short1": "E-1 · aceeași limbă",
"short2": "E-2 · cultură apropiată",
"short3": "E-3 · aproape, altă cultură",
"title1": "E-1 · Vorbesc aceeași limbă",
"title2": "E-2 · O cultură apropiată",
"title3": "E-3 · Aproape, o altă cultură",
"long1": "Credincioși care vorbesc limba acestui grup. Pot împărtăși Evanghelia fără o barieră de limbă, iar o campanie în această limbă îi poate mobiliza.",
"long2": "Credincioși dintr-un popor înrudit: același grup de popoare sau aceeași familie de limbi în același bloc de afinitate. Un pas cultural mai scurt decât a începe de la zero.",
"long3": "Credincioși care locuiesc aproape (sub 600 km sau în aceeași țară), dar de altă cultură. Împart regiunea, nu limba."
},
"p": {
"title0": "Niciun vecin atins găsit",
"title1": "Există credincioși în limba sa",
"title2": "Cei mai apropiați credincioși sunt dintr-o cultură apropiată",
"title3": "Cei mai apropiați credincioși sunt dintr-o cultură îndepărtată",
"long0": "În aceste date niciun grup atins nu se potrivește cu acest popor ca limbă, cultură sau distanță.",
"long1": "Undeva, un grup atins vorbește limba acestui popor. Mobilizarea lui este calea cea mai directă.",
"long2": "Niciun grup atins nu vorbește această limbă, dar popoarele înrudite au credincioși.",
"long3": "În apropiere sunt doar credincioși de altă cultură. Pentru a ajunge la acest popor e nevoie de lucrători interculturali."
},
"res": {
"bible": "Biblie",
"film": "Filmul ISUS",
"audio": "Audio",
"radio": "Radio"
},
"mob": {
"legend": "Legenda hărții",
"legendUnreached": "Grup neatins",
"searchPlaceholder": "Căutați un grup neatins, o țară sau o limbă",
"pFilter": "Filtrați după nivelul P",
"pAll": "Toate",
"loading": "Se încarcă popoarele…",
"intro": "Alegeți un grup neatins (cele mai mari primele) sau dați clic pe un punct roșu de pe hartă pentru a vedea pe cine să mobilizați.",
"noMatch": "Niciun rezultat.",
"back": "Toate grupurile",
"people": "{n} oameni",
"believersNear": "Aproximativ {n} credincioși printre vecinii de mai jos.",
"resources": "Resurse în limba sa",
"export": "Exportați segmentele de public (CSV)",
"openBridge": "Planificați o campanie în {lang}",
"samePeople": "același popor",
"cardStats": "~{b} credincioși · la {km} km",
"noNeighbors": "Niciun vecin atins în aceste date.",
"caveat": "Estimări din datele peoplegroups.org. Verificați realitatea locală înainte de a acționa.",
"loadError": "Datele despre popoare nu au putut fi încărcate: {message}"
},
"camp": {
"intro": "Limbi-punte: limbi vorbite atât de credincioși, cât și de popoare neatinse. O campanie în acea limbă, difuzată acolo unde trăiesc credincioșii, îi poate mobiliza să ajungă la cei neatinși în propria limbă.",
"searchPlaceholder": "Căutați o limbă sau o țară",
"minBelievers": "Cel puțin",
"exportAll": "Exportați lista (CSV)",
"unreachedPeople": "{n} neatinși",
"believers": "~{n} credincioși",
"believersIn": "Credincioși în: {list}",
"back": "Toate limbile",
"bigBelievers": "credincioși de mobilizat ({g} grupuri, {c} țări)",
"bigUnreached": "neatinși de atins ({g} grupuri, {c} țări)",
"export": "Exportați această campanie (CSV)",
"whereToRun": "Unde s-o derulați: credincioși pe țări",
"believersInGroups": "~{n} în {g} grupuri",
"whoTheyReach": "Pe cine pot atinge"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "Карта отправки",
"flowerMesh": "Цветочная сеть",
"langCluster": "Языковые кластеры",
"flowers": "Цветы"
"flowers": "Цветы",
"mobilize": "Мобилизация",
"campaigns": "Кампании"
},
"hints": {
"cockpit": "центр перекрёстной фильтрации",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} НН · {s} достигнутых отправителей",
"noSenders": "Нет подходящих достигнутых отправителей с известным центром страны для этой цели."
},
"about": {
"button": "Как это работает",
"title": "Мобилизация близких соседей",
"close": "Закрыть",
"why": "Лучше всего достучаться до непросвещённого народа обычно могут верующие, самые близкие к нему по языку и культуре. Этот инструмент находит их для каждой непросвещённой группы по шкалам E и P Ральфа Д. Уинтера, чтобы вы могли спланировать, кого мобилизовать, на каком языке и где.",
"eTitle": "Шкала E: какое расстояние должен преодолеть свидетель",
"pTitle": "Шкала P: расстояние между народом и церковью",
"dataTitle": "Данные и ограничения",
"data": "Просвещённым считается народ, где не менее 2% евангельских христиан (определение IMB). Язык и культура определяются по коду языка и таксономии народов IMB (кластер, блок родства, языковая семья): хорошие общедоступные приближения, а не полевые оценки. Число верующих оценено по численности населения и диапазонам доли евангельских христиан; используйте его для оценки аудитории, а не как точный подсчёт.",
"source": "Данные о народах: peoplegroups.org (IMB), people_groups.csv. Шкалы E и P: Ральф Д. Уинтер, Лозаннский конгресс, 1974."
},
"e": {
"short1": "E-1 · тот же язык",
"short2": "E-2 · близкая культура",
"short3": "E-3 · рядом, другая культура",
"title1": "E-1 · Они говорят на том же языке",
"title2": "E-2 · Близкая культура",
"title3": "E-3 · Рядом, другая культура",
"long1": "Верующие, говорящие на языке этой группы. Они могут делиться Евангелием без языкового барьера, а кампания на этом языке может их мобилизовать.",
"long2": "Верующие из родственного народа: тот же кластер народов или та же языковая семья в том же блоке родства. Культурный шаг короче, чем начинать с нуля.",
"long3": "Верующие, живущие рядом (в пределах 600 км или в той же стране), но другой культуры. Их объединяет регион, а не язык."
},
"p": {
"title0": "Просвещённые соседи не найдены",
"title1": "Есть верующие на его языке",
"title2": "Ближайшие верующие из близкой культуры",
"title3": "Ближайшие верующие из далёкой культуры",
"long0": "В этих данных ни одна просвещённая группа не совпадает с этим народом по языку, культуре или расстоянию.",
"long1": "Где-то просвещённая группа говорит на языке этого народа. Мобилизовать её — самый прямой путь.",
"long2": "Ни одна просвещённая группа не говорит на этом языке, но у родственных народов есть верующие.",
"long3": "Рядом только верующие другой культуры. Чтобы достичь этот народ, нужны межкультурные служители."
},
"res": {
"bible": "Библия",
"film": "Фильм «Иисус»",
"audio": "Аудио",
"radio": "Радио"
},
"mob": {
"legend": "Легенда карты",
"legendUnreached": "Непросвещённая группа",
"searchPlaceholder": "Поиск непросвещённой группы, страны или языка",
"pFilter": "Фильтр по уровню P",
"pAll": "Все",
"loading": "Загрузка народов…",
"intro": "Выберите непросвещённую группу (сначала крупные) или нажмите на красную точку на карте, чтобы увидеть, кого мобилизовать.",
"noMatch": "Ничего не найдено.",
"back": "Все группы",
"people": "{n} чел.",
"believersNear": "Около {n} верующих среди соседей ниже.",
"resources": "Материалы на его языке",
"export": "Экспорт сегментов аудитории (CSV)",
"openBridge": "Спланировать кампанию на языке {lang}",
"samePeople": "тот же народ",
"cardStats": "~{b} верующих · {km} км",
"noNeighbors": "В этих данных нет просвещённых соседей.",
"caveat": "Оценки по данным peoplegroups.org. Проверьте местные реалии, прежде чем действовать.",
"loadError": "Не удалось загрузить данные о народах: {message}"
},
"camp": {
"intro": "Языки-мосты: языки, на которых говорят и верующие, и непросвещённые народы. Одна кампания на этом языке там, где живут верующие, может мобилизовать их, чтобы достичь непросвещённых на их родном языке.",
"searchPlaceholder": "Поиск языка или страны",
"minBelievers": "Не менее",
"exportAll": "Экспорт списка (CSV)",
"unreachedPeople": "{n} непросвещённых",
"believers": "~{n} верующих",
"believersIn": "Верующие в: {list}",
"back": "Все языки",
"bigBelievers": "верующих для мобилизации ({g} групп, {c} стран)",
"bigUnreached": "непросвещённых, которых можно достичь ({g} групп, {c} стран)",
"export": "Экспорт этой кампании (CSV)",
"whereToRun": "Где её проводить: верующие по странам",
"believersInGroups": "~{n} в {g} группах",
"whoTheyReach": "Кого они могут достичь"
}
}

View file

@ -6,7 +6,9 @@
"sendMap": "差派地图",
"flowerMesh": "花网",
"langCluster": "语言群",
"flowers": "花朵"
"flowers": "花朵",
"mobilize": "动员",
"campaigns": "宣传活动"
},
"hints": {
"cockpit": "交叉筛选中心",
@ -172,5 +174,80 @@
"sm": {
"counts": "{u} 个未得之民 · {s} 个已得差派方",
"noSenders": "此目标没有具有已知国家中心点的合格已得差派方。"
},
"about": {
"button": "使用说明",
"title": "动员近邻",
"close": "关闭",
"why": "最适合接触未得之民的信徒,通常是在语言和文化上与他们最接近的人。本工具依据拉尔夫·温特(Ralph D. Winter)的 E 等级和 P 等级,为每个未得之民群体找出这些信徒,帮助你规划动员谁、用哪种语言、在哪里。",
"eTitle": "E 等级:见证者需跨越的距离",
"pTitle": "P 等级:一个民族与教会之间的距离",
"dataTitle": "数据与局限",
"data": "\"已得之民\"指福音派信徒至少占 2%(IMB 的定义)。语言和文化依据语言代码和 IMB 民族分类(族群、亲缘区块、语系)判断:是很好的公开近似指标,而非实地评估。信徒人数由人口和福音派比例区间估算,用于估计受众规模,不是精确统计。",
"source": "民族数据:peoplegroups.org(IMB),people_groups.csv。E 等级与 P 等级:拉尔夫·温特,洛桑大会,1974 年。"
},
"e": {
"short1": "E-1 · 同一语言",
"short2": "E-2 · 相近文化",
"short3": "E-3 · 邻近,不同文化",
"title1": "E-1 · 说同一种语言",
"title2": "E-2 · 相近的文化",
"title3": "E-3 · 邻近但文化不同",
"long1": "说这个群体语言的信徒。他们无需跨越语言障碍就能传福音,用这种语言开展的宣传活动可以动员他们。",
"long2": "来自相关民族的信徒:同一民族群,或同一亲缘区块中的同一语系。比从零开始的文化跨度更短。",
"long3": "住在附近(600 公里以内或同一国家)但文化不同的信徒。他们共享地区,而非语言。"
},
"p": {
"title0": "未找到已得之民近邻",
"title1": "有说其本族语言的信徒",
"title2": "最近的信徒来自相近文化",
"title3": "最近的信徒来自遥远文化",
"long0": "在此数据中,没有已得之民群体在语言、文化或距离上与该民族相符。",
"long1": "某处有已得之民群体说这个民族的语言。动员他们是最直接的途径。",
"long2": "没有已得之民群体说这种语言,但相关民族中有信徒。",
"long3": "附近只有不同文化的信徒。接触这个民族需要跨文化工人。"
},
"res": {
"bible": "圣经",
"film": "《耶稣传》电影",
"audio": "音频",
"radio": "广播"
},
"mob": {
"legend": "地图图例",
"legendUnreached": "未得之民群体",
"searchPlaceholder": "搜索未得之民群体、国家或语言",
"pFilter": "按 P 等级筛选",
"pAll": "全部",
"loading": "正在加载民族…",
"intro": "选择一个未得之民群体(按规模排序),或点击地图上的红点,查看可以动员谁。",
"noMatch": "没有匹配项。",
"back": "所有群体",
"people": "{n} 人",
"believersNear": "下列近邻中约有 {n} 名信徒。",
"resources": "其语言的资源",
"export": "导出受众细分(CSV)",
"openBridge": "策划一场 {lang} 宣传活动",
"samePeople": "同一民族",
"cardStats": "约 {b} 名信徒 · 相距 {km} 公里",
"noNeighbors": "此数据中没有已得之民近邻。",
"caveat": "根据 peoplegroups.org 数据估算。行动前请核实当地实际情况。",
"loadError": "无法加载民族数据:{message}"
},
"camp": {
"intro": "桥梁语言:信徒和未得之民都使用的语言。在信徒居住地用这种语言开展一场宣传活动,就能动员他们用对方的母语接触未得之民。",
"searchPlaceholder": "搜索语言或国家",
"minBelievers": "至少",
"exportAll": "导出列表(CSV)",
"unreachedPeople": "{n} 未得之民",
"believers": "约 {n} 名信徒",
"believersIn": "信徒所在:{list}",
"back": "所有语言",
"bigBelievers": "名可动员的信徒({g} 个群体,{c} 个国家)",
"bigUnreached": "名待接触的未得之民({g} 个群体,{c} 个国家)",
"export": "导出此活动(CSV)",
"whereToRun": "在哪里开展:各国信徒",
"believersInGroups": "约 {n},分布于 {g} 个群体",
"whoTheyReach": "他们可以接触的人群"
}
}

View file

@ -188,6 +188,9 @@ onMounted(() => {
if (cur && cur !== view.value) view.value = cur
})
window.addEventListener(SHOW_ON_MAP, onShowOnMap)
// profile-config `view` opens a chosen pane ("map" or "wall"), so an embed can start on the map.
const opening = cfg.value?.view
if (opening && opening !== firstPaneId.value && spec.value.panes.some((p) => p.id === opening)) go(opening)
})
onBeforeUnmount(() => {

View file

@ -428,8 +428,11 @@ useShadowStyles(`
gap via a small negative margin for a comfortable hit target. Never rendered on narrow/mobile. */
.rd-rz{ position:relative; z-index:6; background:transparent; touch-action:none; transition:background .15s; }
.rd-rz::after{ content:''; position:absolute; background:transparent; border-radius:var(--r-pill,999px); transition:background .15s; }
.rd-rz-facet{ grid-area:facets; justify-self:end; align-self:stretch; width:8px; margin-right:-4px; cursor:col-resize; }
.rd-rz-rail{ grid-area:rail; justify-self:start; align-self:stretch; width:8px; margin-left:-4px; cursor:col-resize; }
/* Logical margins: the grid mirrors under dir=rtl (grid-template-columns + justify-self both
follow the inline direction), so a physical margin-right put the handle's 4px overhang on the
wrong side of its own column in Arabic. go-int's board already used the logical pair. */
.rd-rz-facet{ grid-area:facets; justify-self:end; align-self:stretch; width:8px; margin-inline-end:-4px; cursor:col-resize; }
.rd-rz-rail{ grid-area:rail; justify-self:start; align-self:stretch; width:8px; margin-inline-start:-4px; cursor:col-resize; }
.rd-rz-table{ grid-area:map; align-self:end; justify-self:stretch; height:8px; margin-bottom:-4px; cursor:row-resize; }
.rd-rz-facet::after, .rd-rz-rail::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:3px; height:34px; }
.rd-rz-table::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:34px; height:3px; }

View file

@ -91,7 +91,14 @@ if (!existsSync(ENGINE)) { console.log(`${TAG}: SKIP — no ${ENGINE.replace(ROO
{
const src = readFileSync(ENGINE, 'utf8')
const code = src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
if (!/direction === 'rtl'/.test(code)) {
// ★ ASSERT THE BEHAVIOUR, NOT THE PHRASING. This arm used to require the literal
// `direction === 'rtl'`, which pinned one spelling of the probe: moving it from the shell to the
// drag handle (so the sign matches the element the GRID lays out with) kept the behaviour and
// broke the gate. A check that fails on a correct refactor teaches people to edit the check.
// What matters is that the engine READS a computed direction and FLIPS a sign from it.
const readsDirection = /getComputedStyle/.test(code) && /\.direction\b/.test(code) && /'rtl'/.test(code)
const flipsSign = /-1/.test(code)
if (!readsDirection || !flipsSign) {
fails.push('D useDashboardLayout never reads the computed direction, so the drag cannot mirror: in ar the facet handle moves the wrong rail.')
} else {
if (!/startFacet \+ xSign \* \(ev\.clientX - startX\)/.test(code) || !/startRail - xSign \* \(ev\.clientX - startX\)/.test(code)) {

View file

@ -58,7 +58,7 @@ const GLOBALS = [
'fetch', 'Headers', 'Request', 'Response', 'AbortController', 'AbortSignal',
'URL', 'URLSearchParams', 'Blob', 'File', 'FileReader', 'FormData', 'TextEncoder', 'TextDecoder',
'Event', 'CustomEvent', 'EventTarget', 'MessageChannel', 'BroadcastChannel',
'IntersectionObserver', 'ResizeObserver', 'MutationObserver', 'PerformanceObserver', 'performance',
'IntersectionObserver', 'DOMException', 'createImageBitmap', 'ResizeObserver', 'MutationObserver', 'PerformanceObserver', 'performance',
'HTMLElement', 'HTMLCanvasElement', 'HTMLImageElement', 'Element', 'Node', 'NodeList', 'DOMParser',
'Image', 'Audio', 'Intl', 'getComputedStyle', 'matchMedia', 'alert', 'confirm', 'prompt',
'localStorage', 'sessionStorage', 'devicePixelRatio', 'innerWidth', 'innerHeight',
@ -256,7 +256,11 @@ function vueFiles(dir, out = []) {
const p = join(dir, e)
const st = statSync(p)
if (st.isDirectory()) vueFiles(p, out)
else if (e.endsWith('.vue')) out.push(p)
// ★ .js MODULES TOO, since the NEUTRAL P1 (2026-09-27). This gate read only .vue script blocks,
// and the bug that emptied Big Picture's Religion and Region charts was a plain .js module:
// useDashboardAggregations.js read NEUTRAL five times and never imported it. A bundle compiles
// .js and .vue alike; the gate was seeing half of what ships.
else if (e.endsWith('.vue') || e.endsWith('.js')) out.push(p)
}
return out
}
@ -269,7 +273,15 @@ let scanned = 0
for (const file of files) {
const src = readFileSync(file, 'utf8')
let descriptor
try { ({ descriptor } = parseSFC(src, { filename: file })) } catch (e) { unparsed.push([file, e.message]); continue }
if (file.endsWith('.js')) {
// A .js module is its own script block. Running it through the SFC parser is what broke the first
// attempt at this: parseSFC found no <script>, the content came back shifted, and acorn reported
// "Unexpected token (2:1)" — the second line of the file's own doc comment. offset 0 keeps the
// reported line numbers the file's real ones.
descriptor = { scriptSetup: null, script: { content: src, loc: { start: { offset: 0 } } } }
} else {
try { ({ descriptor } = parseSFC(src, { filename: file })) } catch (e) { unparsed.push([file, e.message]); continue }
}
// ★ BOTH SCRIPT BLOCKS, because an SFC may have both and they SHARE a scope. LegendTools.vue
// exports a function from its plain <script> and uses it in <script setup>; reading only one
// block reported that as a free identifier, which would have taught everyone to ignore this gate

View file

@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* build-near-neighbors.mjs — for every unreached people group, the REACHED groups whose believers are
* culturally nearest to it, measured on Ralph D. Winter's E-scale.
*
* node plugin-designs/upg-neighbor/scripts/build-near-neighbors.mjs
*
* READS data/people-groups/people_groups.csv peoplegroups.org public download (see its meta)
* WRITES data/tables/near-neighbors.json served as assets/tables/near-neighbors.json
*
* THE FRAMEWORK, in our words. Winter (Lausanne 1974) measured evangelism by the cultural distance a
* witness has to cross: E-1 inside one's own language and culture, E-2 into a NEAR culture, E-3 into
* a DISTANT one. His companion P-scale measures the same distance from the other side: how far a
* people has to go to find a church. The strategic point is that believers in a near culture can
* reach a people far more naturally than anyone else, so the first mobilization question for an
* unreached group is "who are its E-1 and E-2 believers?". That is what this table answers.
*
* HOW EACH LEVEL IS DECIDED FROM THE DATA (a reached group R, an unreached group U):
* E-1 R speaks U's language (same LanguageCode, ISO 639-3). Same ROP25 code as well means the
* SAME people living in another country (a diaspora): flagged `samePeople`.
* E-2 a different language, but a near culture: the same ROP2 people cluster, or the same language
* family inside the same ROP1 affinity bloc.
* E-3 none of those, but geographically close: within NEAR_KM, or in the same country.
* P-level of U = the smallest E-level any reached group reaches it at (P-1 when an E-1 neighbor
* exists, P-2 when only E-2, P-3 when only E-3 or none).
* These are proxies. Language code and IMB's people taxonomy stand in for "culture"; they are the
* best public signal there is, not a field survey.
*
* REACHED / UNREACHED follow the IMB definition the source uses: EvangelicalLevel >= 2% is reached
* (GSEC 4-6 in this file), below 2% or "No Known Evangelicals" is unreached.
* `believersEst` = Population x the midpoint of the evangelical band. It is an ORDER OF MAGNITUDE for
* sizing a media audience, never a count.
*
* STRICT. The run FAILS on a band it does not know, on a row without coordinates, and on an empty
* result, so a changed source cannot silently produce a thinner table.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const CSV = join(ROOT, 'data/people-groups/people_groups.csv')
const OUT = join(ROOT, 'data/tables/near-neighbors.json')
const NEAR_KM = 600 // E-3 radius: close enough to share a region, a market, a media footprint
const KEEP = { 1: 6, 2: 6, 3: 4 } // neighbors kept per unreached group, per E-level (best first)
const fail = (msg) => { console.error('build-near-neighbors: FAIL — ' + msg); process.exit(1) }
// A quoted-field CSV reader (names and descriptions contain commas, quotes and newlines).
function parseCsv(text) {
const rows = []; let row = []; let f = ''; let q = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (q) { if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++ } else q = false } else f += c }
else if (c === '"') q = true
else if (c === ',') { row.push(f); f = '' }
else if (c === '\n') { row.push(f); rows.push(row); row = []; f = '' }
else if (c !== '\r') f += c
}
if (f || row.length) { row.push(f); rows.push(row) }
const head = rows.shift().map((h) => h.replace(/^/, ''))
return rows.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i]])))
}
// Evangelical band -> [reached?, midpoint %]. Every band the source uses must be listed here.
const BAND = {
'No Known Evangelicals': [false, 0],
'Less than 2%': [false, 1],
'2% or Greater but Less than 5%': [true, 3.5],
'5% or Greater but Less than 10%': [true, 7.5],
'10% or Greater but Less than 15%': [true, 12.5],
'15% or Greater but Less than 20%': [true, 17.5],
'20% or Greater but Less than 30%': [true, 25],
'30% or Greater but Less than 40%': [true, 35],
'40% or Greater but Less than 50%': [true, 45],
'50% or Greater but Less than 75%': [true, 62.5],
'75% or Greater': [true, 80],
}
const avail = (v) => (v === 'Available' ? 1 : 0)
const src = parseCsv(readFileSync(CSV, 'utf8'))
if (!src.length) fail('no rows in people_groups.csv')
const groups = src.map((r, i) => {
const band = BAND[r.EvangelicalLevel]
if (!band) fail(`unknown EvangelicalLevel "${r.EvangelicalLevel}" (${r.PGID})`)
const lat = Number(r.Lat), lng = Number(r.Long)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) fail(`no coordinates for ${r.PGID}`)
const pop = Number(r.Population) || 0
return {
i, pgid: r.PGID, name: r.DisplayName || r.Name, iso3: r.ISOAlpha3, country: r.CountryDisplayName || r.CountryName,
lat, lng, pop, reached: band[0], evPct: band[1], believers: band[0] ? Math.round(pop * band[1] / 100) : 0,
lang: (r.LanguageCode || '').trim(), langName: r.LanguageName || '', family: (r.LanguageFamily || '').trim(),
ethne: r.ROP25Code, cluster: r.ROP2Code, clusterName: r.ROP2Name, bloc: r.ROP1Code, blocName: r.ROP1Name,
religion: r.ReligionDisplayName || r.ReligionName || '', engaged: r.EngagementStatus === 'Engaged' ? 1 : 0,
// what already exists in this group's language: the content a campaign could point to
bible: avail(r.BibleAvailability), film: avail(r.JesusFilmAvailability), audio: avail(r.AudioResourceAvailability),
radio: avail(r.RadioProgramAvailability),
}
})
const reached = groups.filter((g) => g.reached)
const unreached = groups.filter((g) => !g.reached)
const index = (key) => {
const m = new Map()
for (const g of reached) { const k = key(g); if (!k) continue; if (!m.has(k)) m.set(k, []); m.get(k).push(g) }
return m
}
const byLang = index((g) => g.lang)
const byCluster = index((g) => g.cluster)
const byFamilyBloc = index((g) => (g.family ? g.family + '|' + g.bloc : ''))
const R = Math.PI / 180
function km(a, b) {
const dLat = (b.lat - a.lat) * R, dLng = (b.lng - a.lng) * R
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * R) * Math.cos(b.lat * R) * Math.sin(dLng / 2) ** 2
return Math.round(12742 * Math.asin(Math.sqrt(h)))
}
// Best first by a GRAVITY score: estimated believers, discounted by distance (halved at ~500 km). A
// large church far away and a small one next door are both real options; this weighs them the way a
// mobilizer would (audience size, but a shared region matters). `tier` puts a stronger kind of match
// first inside one E-level (E-2: same people cluster before same language family only).
const HALF_KM = 500
const score = (x) => x.g.believers / (1 + (x.km / HALF_KM) ** 2)
const rank = () => (a, b) => (a.tier - b.tier) || (score(b) - score(a))
const links = {}
const pCount = { 1: 0, 2: 0, 3: 0, none: 0 }
const used = new Set()
for (const u of unreached) {
const seen = new Set()
const pick = (level, cands, tierOf = () => 0) => {
const out = []
for (const g of cands || []) { if (seen.has(g.i) || g.i === u.i) continue; out.push({ g, km: km(u, g), tier: tierOf(g) }) }
out.sort(rank())
const kept = out.slice(0, KEEP[level])
for (const x of kept) seen.add(x.g.i)
return kept.map((x) => [x.g.i, level, x.km, x.g.ethne === u.ethne ? 1 : 0])
}
const e1 = u.lang ? pick(1, byLang.get(u.lang)) : []
const e2 = pick(2, [...new Set([...(byCluster.get(u.cluster) || []), ...(u.family ? byFamilyBloc.get(u.family + '|' + u.bloc) || [] : [])])],
(g) => (g.cluster === u.cluster ? 0 : 1))
const near = []
for (const g of reached) { if (seen.has(g.i)) continue; const d = km(u, g); if (d <= NEAR_KM || g.iso3 === u.iso3) near.push(g) }
const e3 = pick(3, near)
const all = [...e1, ...e2, ...e3]
const p = e1.length ? 1 : e2.length ? 2 : e3.length ? 3 : 0
pCount[p || 'none']++
if (all.length) { links[u.i] = all; for (const l of all) used.add(l[0]) }
}
if (!Object.keys(links).length) fail('no unreached group has any reached neighbor (vacuous run)')
// BRIDGE LANGUAGES — the mass-media view. A language spoken both by reached groups (believers who can
// be mobilized in it) and by unreached groups (the people they would reach, at E-1). One campaign in
// that language, aimed where the believers are, mobilizes all of them at once. Ranked by the
// unreached population it can reach. Computed over EVERY reached speaker, not only the linked ones.
const bridges = []
for (const [lang, rs] of byLang) {
const us = unreached.filter((u) => u.lang === lang)
if (!us.length) continue
const any = (k) => (rs.some((g) => g[k]) || us.some((g) => g[k]) ? 1 : 0)
bridges.push({
lang, rs, us,
believers: rs.reduce((a, g) => a + g.believers, 0),
unreachedPop: us.reduce((a, g) => a + g.pop, 0),
res: [any('bible'), any('film'), any('audio'), any('radio')],
})
}
bridges.sort((a, b) => (b.unreachedPop - a.unreachedPop) || (b.believers - a.believers))
for (const b of bridges) for (const g of b.rs) used.add(g.i)
// Ship only the groups a view can show: every unreached group, plus the reached groups some link or
// bridge uses.
const shipped = groups.filter((g) => !g.reached || used.has(g.i))
const newIdx = new Map(shipped.map((g, j) => [g.i, j]))
const COLS = ['pgid', 'name', 'iso3', 'country', 'lat', 'lng', 'pop', 'reached', 'evPct', 'believers', 'lang', 'langName',
'family', 'ethne', 'cluster', 'clusterName', 'bloc', 'blocName', 'religion', 'engaged', 'bible', 'film', 'audio', 'radio']
// Repeated strings are dictionary-encoded (the value in the row is an index into dict[col]); this
// halves the file. A reader decodes with dict[col][row[j]].
const DICT_COLS = ['iso3', 'country', 'lang', 'langName', 'family', 'cluster', 'clusterName', 'bloc', 'blocName', 'religion']
const dict = Object.fromEntries(DICT_COLS.map((c) => [c, []]))
const dictIdx = Object.fromEntries(DICT_COLS.map((c) => [c, new Map()]))
const enc = (c, v) => { const m = dictIdx[c]; if (!m.has(v)) { m.set(v, dict[c].length); dict[c].push(v) } return m.get(v) }
const rows = shipped.map((g) => COLS.map((c) => {
const v = g[c]
if (DICT_COLS.includes(c)) return enc(c, v)
if (typeof v === 'boolean') return v ? 1 : 0
if (c === 'lat' || c === 'lng') return Math.round(v * 100) / 100
return v
}))
const outLinks = {}
for (const [ui, ls] of Object.entries(links)) outLinks[newIdx.get(+ui)] = ls.map(([ri, e, d, same]) => [newIdx.get(ri), e, d, same])
const outBridges = bridges.map((b) => ({
lang: b.lang, believers: b.believers, unreachedPop: b.unreachedPop, res: b.res,
reached: b.rs.map((g) => newIdx.get(g.i)), unreached: b.us.map((g) => newIdx.get(g.i)),
}))
const out = {
meta: {
title: 'Near neighbors: reached groups nearest to each unreached group, on the E-scale',
grain: 'rows = one per people group x country (peoplegroups.org grain), columns in cols; dictionary columns hold an index into dict[col]. links[u] = [reachedRow, eLevel, km, samePeople] for unreached row u',
framework: 'Ralph D. Winter\'s E-scale (E-1 same language and culture, E-2 near culture, E-3 distant culture) and P-scale, Lausanne 1974. Levels here are PROXIES from language code and the IMB people taxonomy, not field assessments.',
rules: {
reached: 'EvangelicalLevel >= 2% (IMB definition; GSEC 4-6)',
e1: 'same LanguageCode (ISO 639-3); samePeople = same ROP25 ethne in another country',
e2: 'different language, same ROP2 people cluster, or same LanguageFamily within the same ROP1 affinity bloc',
e3: `none of the above, within ${NEAR_KM} km or in the same country`,
p: 'P-level of an unreached group = the smallest E-level any reached neighbor reaches it at',
kept: KEEP, rank: `inside one E-level: stronger match kind first (E-2: people cluster before language family), then estimated believers / (1 + (km/${HALF_KM})^2)`,
},
believersEst: 'Population x midpoint of the evangelical band: an order of magnitude for sizing an audience, not a count',
bridgeRule: 'bridges = languages spoken by at least one reached AND one unreached group; reached/unreached = row indexes; res = [bible, film, audio, radio] available in that language for any of them; ranked by unreached population',
counts: { bridges: bridges.length, groups: groups.length, reached: reached.length, unreached: unreached.length, shipped: shipped.length, unreachedWithNeighbors: Object.keys(links).length, pLevel: pCount },
source: {
name: 'peoplegroups.org — people_groups.csv (IMB)',
url: 'https://peoplegroups.org/wp-content/uploads/people_groups.csv',
attribution: 'People group data: peoplegroups.org',
},
generated_by: 'plugin-designs/upg-neighbor/scripts/build-near-neighbors.mjs',
},
cols: COLS,
dict,
rows,
links: outLinks,
bridges: outBridges,
}
writeFileSync(OUT, JSON.stringify(out) + '\n')
const bytes = Buffer.byteLength(JSON.stringify(out))
console.log(`build-near-neighbors: ${bridges.length} bridge languages · ${unreached.length} unreached · ${reached.length} reached · ${Object.keys(links).length} with neighbors · P-1 ${pCount[1]} · P-2 ${pCount[2]} · P-3 ${pCount[3]} · none ${pCount.none} · ${shipped.length} rows shipped · ${(bytes / 1e6).toFixed(2)} MB`)

View file

@ -1,195 +0,0 @@
<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">{{ $t('neighbor.title.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="$t('neighbor.remove', { label: c.label })">
{{ c.label }} <span class="x">×</span>
</button>
</div>
<input class="cpt-search" type="search" :value="store.search.value" @input="onSearch"
:placeholder="$t('neighbor.searchCockpit')" :aria-label="$t('neighbor.search')" />
<span class="cpt-counter">
<b>{{ store.counts.value.senders.toLocaleString() }}</b> {{ $t('neighbor.cockpit.senders') }}
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> {{ $t('neighbor.cockpit.clustersHidden', { n: store.counts.value.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="$t('neighbor.cockpit.viewSwitch')">
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">{{ $t('neighbor.cockpit.map') }}</button>
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">{{ $t('neighbor.cockpit.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> {{ $t('neighbor.cockpit.unreachedTarget') }}</span>
<span class="cpt-lg">{{ $t('neighbor.strip.pctReached') }}
<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> {{ $t('neighbor.cockpit.legendSend') }}</span>
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> {{ $t('neighbor.cockpit.legendSelection') }}</span>
</div>
<!-- loading / error overlay -->
<div v-if="!store.ready.value" class="cpt-overlay">{{ $t('neighbor.cockpit.loading') }}</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,239 @@
<script setup>
/**
* campaigns.vue — BRIDGE LANGUAGES: the mass-media planner.
*
* A bridge language is spoken both by reached groups (believers who can be mobilized in it) and by
* unreached groups (whom those believers could reach at E-1, in their own language). One campaign in
* that language, placed where the believers are, mobilizes all of them at once. The list is ranked
* by the unreached population a campaign could reach; selecting one shows where to run it, whom it
* reaches, and what content already exists in the language. Data: nearData.js.
*/
import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
import { useNeighborT } from './composables/useNeighborT.js'
import { loadNearData, E_COLORS, UNREACHED, NAVY, fold, fmtNum, downloadCsv } from './nearData.js'
const nt = useNeighborT()
const profileConfig = inject('profileConfig', ref({}))
const mapboxToken = inject('mapboxToken', ref(''))
const nbState = inject('nbState')
const cfg = computed(() => profileConfig?.value || {})
const mapContainer = ref(null)
const data = ref(null)
const loadError = ref('')
const mapError = ref('')
const query = ref('')
const minBelievers = ref(1000) // hide languages with too few believers to run a campaign through
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 ?? [45, 20],
zoom: cfg.value.zoom ?? 1.6,
minZoom: 1, maxZoom: 11,
})
const list = computed(() => {
if (!data.value) return []
const q = fold(query.value.trim())
return data.value.bridges.filter((b) => b.believers >= minBelievers.value &&
(!q || fold(b.langName).includes(q) || b.reachedCountries.some((c) => fold(c).includes(q)) || b.unreachedCountries.some((c) => fold(c).includes(q))))
})
const active = computed(() => data.value?.bridges.find((b) => b.lang === nbState.lang) || null)
function pick(b) { nbState.lang = b.lang }
function back() { nbState.lang = null }
function openGroup(u) { nbState.pgid = u.pgid; nbState.view = 'mobilize' }
// Where to run the campaign: believers per country, largest first.
const audience = computed(() => {
const b = active.value
if (!b) return []
const m = new Map()
for (const g of b.reached) { const x = m.get(g.country) || { country: g.country, believers: 0, groups: 0 }; x.believers += g.believers; x.groups++; m.set(g.country, x) }
return [...m.values()].sort((a, b2) => b2.believers - a.believers)
})
const reachedTargets = computed(() => (active.value ? [...active.value.unreached].sort((a, b) => b.pop - a.pop) : []))
function exportCampaign() {
const b = active.value
if (!b) return
const header = ['campaign_language', 'language_code', 'role', 'group', 'country', 'population', 'est_believers', 'lat', 'lng', 'pgid']
const rows = [
...b.reached.map((g) => [b.langName, b.lang, 'mobilize (reached)', g.name, g.country, g.pop, g.believers, g.lat, g.lng, g.pgid]),
...b.unreached.map((g) => [b.langName, b.lang, 'reach (unreached)', g.name, g.country, g.pop, '', g.lat, g.lng, g.pgid]),
]
downloadCsv(`bridge-language-${b.lang}.csv`, header, rows)
}
function exportAll() {
const header = ['campaign_language', 'language_code', 'unreached_population', 'unreached_groups', 'est_believers', 'reached_groups',
'believers_in_countries', 'unreached_in_countries', 'bible', 'jesus_film', 'audio', 'radio']
const rows = list.value.map((b) => [b.langName, b.lang, b.unreachedPop, b.unreached.length, b.believers, b.reached.length,
b.reachedCountries.join('; '), b.unreachedCountries.join('; '),
b.res.bible ? 'yes' : 'no', b.res.film ? 'yes' : 'no', b.res.audio ? 'yes' : 'no', b.res.radio ? 'yes' : 'no'])
downloadCsv('bridge-languages.csv', header, rows)
}
// ─── Map: believers (green) and the unreached they would reach (red) ─────────
const SRC_R = 'cp-r', SRC_U = 'cp-u'
const fc = (features) => ({ type: 'FeatureCollection', features })
const pt = (g) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] }, properties: { id: g.pgid, pop: g.pop, b: g.believers } })
function setSrc(m, id, d) { const s = m.getSource(id); if (s) s.setData(d); else m.addSource(id, { type: 'geojson', data: d }) }
function repaint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
const b = active.value
setSrc(m, SRC_U, fc(b ? b.unreached.map(pt) : []))
setSrc(m, SRC_R, fc(b ? b.reached.map(pt) : []))
if (!m.getLayer('cp-u')) m.addLayer({ id: 'cp-u', type: 'circle', source: SRC_U, paint: {
'circle-color': UNREACHED, 'circle-opacity': 0.7, 'circle-stroke-width': 0.6, 'circle-stroke-color': '#fff',
'circle-radius': ['interpolate', ['linear'], ['ln', ['max', ['get', 'pop'], 1]], 6, 2, 12, 4, 16, 8, 19, 12] } })
if (!m.getLayer('cp-r')) m.addLayer({ id: 'cp-r', type: 'circle', source: SRC_R, paint: {
'circle-color': E_COLORS[1], 'circle-opacity': 0.9, 'circle-stroke-width': 1.2, 'circle-stroke-color': '#fff',
'circle-radius': ['interpolate', ['linear'], ['ln', ['max', ['get', 'b'], 1]], 2, 3, 8, 5, 12, 8, 15, 12] } })
}
function frame() {
const m = map.value, b = active.value
if (!m || !b) return
const pts = [...b.reached, ...b.unreached]
const lngs = pts.map((g) => g.lng), lats = pts.map((g) => g.lat)
const w = mapContainer.value?.clientWidth || 0, h = mapContainer.value?.clientHeight || 0
// The panel covers the left 440px on wide screens, the bottom 55% on a phone: frame what you can see.
const padding = w > 760 ? { top: 60, bottom: 60, left: 440, right: 60 } : { top: 40, bottom: Math.round(h * 0.55) + 20, left: 30, right: 30 }
try {
m.stop()
m.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding, maxZoom: 5.5, duration: 900 })
} catch (_) { /* a camera call on a map mid-teardown is not worth an error */ }
}
watch(() => [nbState.lang, data.value], () => { repaint(); frame() })
onMounted(async () => {
loadNearData(profileConfig).then((d) => { data.value = d; repaint(); frame() })
.catch((e) => { loadError.value = nt('neighbor.mob.loadError', { message: String(e.message || e) }) })
await initializeMap()
const m = map.value
if (!m) { mapError.value = nt('messages.mapUnavailable'); return }
whenStyleReady(m, () => {
setProjectionOn(m, 'mercator'); repaint(); frame()
m.on('click', 'cp-u', (ev) => {
const id = ev.features?.[0]?.properties?.id
const u = active.value?.unreached.find((g) => g.pgid === id)
if (u) openGroup(u)
})
m.on('mouseenter', 'cp-u', () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', 'cp-u', () => { m.getCanvas().style.cursor = '' })
})
m.on('style.load', repaint)
})
onBeforeUnmount(() => destroy())
useShadowStyles(`
.cp-root { position:absolute; inset:0; background:#f6f7f9; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; color:#1f2937; }
.cp-map { position:absolute; inset:0; }
.cp-panel { position:absolute; top:10px; left:10px; bottom:10px; width:420px; z-index:20; display:flex; flex-direction:column;
background:#fff; border:1px solid #d9dee6; border-radius:12px; box-shadow:0 6px 20px rgba(0,20,60,.12); overflow:hidden; }
.cp-head { padding:12px 14px 10px; border-bottom:1px solid #eef0f4; }
.cp-head p { margin:0 0 8px; font-size:12.5px; line-height:1.4; color:#4b5563; }
.cp-search { width:100%; box-sizing:border-box; border:1px solid #c9d1dd; border-radius:8px; padding:9px 11px; font:14px/1.2 inherit; }
.cp-tools { display:flex; align-items:center; gap:8px; margin-top:8px; font-size:12px; color:#4b5563; flex-wrap:wrap; }
.cp-tools select { border:1px solid #c9d1dd; border-radius:6px; padding:4px 6px; font:12px inherit; }
.cp-btn { border:1px solid ${NAVY}; background:${NAVY}; color:#fff; border-radius:8px; padding:7px 10px; font:600 12px/1 inherit; cursor:pointer; }
.cp-btn.ghost { background:#fff; color:${NAVY}; }
.cp-body { flex:1 1 0; overflow-y:auto; }
.cp-row { display:block; width:100%; text-align:left; border:0; border-bottom:1px solid #f1f3f6; background:#fff; padding:10px 14px; cursor:pointer; font:inherit; color:inherit; }
.cp-row:hover, .cp-row:focus-visible { background:#f5f8ff; }
.cp-row b { font-size:14px; }
.cp-bar { display:flex; gap:10px; margin-top:4px; font-size:12px; color:#4b5563; flex-wrap:wrap; }
.cp-bar .u { color:${UNREACHED}; font-weight:600; }
.cp-bar .r { color:${E_COLORS[1]}; font-weight:600; }
.cp-where { font-size:11.5px; color:#6b7280; margin-top:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cp-brief { padding:12px 14px 16px; }
.cp-back { border:0; background:none; color:${NAVY}; font:600 12px/1 inherit; cursor:pointer; padding:0 0 8px; }
.cp-title { font:700 18px/1.2 inherit; margin:0 0 4px; }
.cp-big { display:flex; gap:10px; margin:10px 0; }
.cp-big div { flex:1; border-radius:10px; padding:10px; font-size:12px; line-height:1.3; }
.cp-big strong { display:block; font-size:18px; }
.cp-res { display:flex; flex-wrap:wrap; gap:6px; margin:0 0 12px; }
.cp-res span { font:600 11px/1 inherit; border-radius:999px; padding:4px 8px; border:1px solid #d1d5db; color:#6b7280; }
.cp-res span.yes { border-color:#9fd3b0; color:#0b5e33; background:#eaf7ef; }
.cp-brief h3 { font:700 13px/1.2 inherit; margin:14px 0 6px; }
.cp-line { display:flex; justify-content:space-between; gap:8px; font-size:12.5px; padding:5px 0; border-bottom:1px solid #f1f3f6; }
.cp-line button { border:0; background:none; padding:0; font:inherit; color:${NAVY}; cursor:pointer; text-align:left; }
.cp-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
.cp-maperr { position:absolute; right:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
@media (max-width:760px) { .cp-panel { top:auto; left:6px; right:6px; bottom:6px; width:auto; height:55%; } }
`, 'upgn-campaigns')
</script>
<template>
<div class="cp-root">
<div ref="mapContainer" class="cp-map"></div>
<div v-if="mapError" class="cp-maperr" role="status">{{ mapError }}</div>
<aside class="cp-panel">
<template v-if="!active">
<div class="cp-head">
<p>{{ $t('neighbor.camp.intro') }}</p>
<input v-model="query" class="cp-search" type="search" :placeholder="$t('neighbor.camp.searchPlaceholder')" :aria-label="$t('neighbor.camp.searchPlaceholder')" />
<div class="cp-tools">
<label>{{ $t('neighbor.camp.minBelievers') }}
<select v-model.number="minBelievers">
<option :value="1">1+</option><option :value="1000">1K+</option><option :value="10000">10K+</option><option :value="100000">100K+</option>
</select>
</label>
<button class="cp-btn ghost" :disabled="!list.length" @click="exportAll">{{ $t('neighbor.camp.exportAll') }}</button>
</div>
</div>
<div class="cp-body">
<div v-if="loadError" class="cp-note" role="alert">{{ loadError }}</div>
<div v-else-if="!data" class="cp-note">{{ $t('neighbor.mob.loading') }}</div>
<template v-else>
<button v-for="b in list" :key="b.lang" class="cp-row" @click="pick(b)">
<b>{{ b.langName }}</b>
<div class="cp-bar">
<span class="u">{{ $t('neighbor.camp.unreachedPeople', { n: fmtNum(b.unreachedPop) }) }}</span>
<span class="r">{{ $t('neighbor.camp.believers', { n: fmtNum(b.believers) }) }}</span>
</div>
<div class="cp-where">{{ $t('neighbor.camp.believersIn', { list: b.reachedCountries.join(', ') }) }}</div>
</button>
<div v-if="!list.length" class="cp-note">{{ $t('neighbor.mob.noMatch') }}</div>
</template>
</div>
</template>
<div v-else class="cp-body">
<div class="cp-brief">
<button class="cp-back" @click="back">← {{ $t('neighbor.camp.back') }}</button>
<h2 class="cp-title">{{ active.langName }}</h2>
<div class="cp-big">
<div :style="{ background: E_COLORS[1] + '1f' }"><strong>{{ fmtNum(active.believers) }}</strong>{{ $t('neighbor.camp.bigBelievers', { g: active.reached.length, c: active.reachedCountries.length }) }}</div>
<div :style="{ background: UNREACHED + '1a' }"><strong>{{ fmtNum(active.unreachedPop) }}</strong>{{ $t('neighbor.camp.bigUnreached', { g: active.unreached.length, c: active.unreachedCountries.length }) }}</div>
</div>
<div class="cp-res" :aria-label="$t('neighbor.mob.resources')">
<span :class="{ yes: active.res.bible }">{{ $t('neighbor.res.bible') }}</span>
<span :class="{ yes: active.res.film }">{{ $t('neighbor.res.film') }}</span>
<span :class="{ yes: active.res.audio }">{{ $t('neighbor.res.audio') }}</span>
<span :class="{ yes: active.res.radio }">{{ $t('neighbor.res.radio') }}</span>
</div>
<button class="cp-btn" @click="exportCampaign">{{ $t('neighbor.camp.export') }}</button>
<h3>{{ $t('neighbor.camp.whereToRun') }}</h3>
<div v-for="a in audience" :key="a.country" class="cp-line">
<span>{{ a.country }}</span><span>{{ $t('neighbor.camp.believersInGroups', { n: fmtNum(a.believers), g: a.groups }) }}</span>
</div>
<h3>{{ $t('neighbor.camp.whoTheyReach') }}</h3>
<div v-for="u in reachedTargets" :key="u.pgid" class="cp-line">
<button @click="openGroup(u)">{{ u.name }} · {{ u.country }}</button><span>{{ fmtNum(u.pop) }}</span>
</div>
<p class="cp-note" style="padding:12px 0 0">{{ $t('neighbor.mob.caveat') }}</p>
</div>
</div>
</aside>
</div>
</template>

View file

@ -1,356 +0,0 @@
<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="$t('neighbor.searchStrip')" :aria-label="$t('neighbor.searchBrush')" />
<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>{{ $t('neighbor.strip.lens') }}</span>
<select :value="store.languageLens.value || ''" @change="onLens" :aria-label="$t('neighbor.strip.lensAria')">
<option value="">{{ $t('neighbor.strip.noLens') }}</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="$t('neighbor.strip.colorAria')">
<span>{{ $t('neighbor.strip.color') }}</span>
<button :class="{ on: store.colorBy.value === 'pct' }" @click="store.setColorBy('pct')">{{ $t('neighbor.strip.pctReached') }}</button>
<button :class="{ on: store.colorBy.value === 'religion' }" @click="store.setColorBy('religion')">{{ $t('neighbor.strip.religion') }}</button>
</div>
</div>
<!-- ── THE 4 CROSSFILTER CHARTS ── -->
<div class="cs-grid">
<!-- 1) sender language bar -->
<section class="cs-card">
<header>
<h4>{{ $t('neighbor.strip.senderLanguage') }}</h4>
<button v-if="store.facets.value.languages.size" class="cs-clr"
@click="store.facets.value.languages.forEach(v => store.toggleFacet('languages', v))">{{ $t('neighbor.strip.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="$t('neighbor.strip.byLanguage')">
<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">{{ $t('neighbor.strip.noSenders') }}</p>
</section>
<!-- 2) sender country bar -->
<section class="cs-card">
<header>
<h4>{{ $t('neighbor.strip.senderCountry') }}</h4>
<button v-if="store.facets.value.countries.size" class="cs-clr"
@click="store.facets.value.countries.forEach(v => store.toggleFacet('countries', v))">{{ $t('neighbor.strip.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="$t('neighbor.strip.byCountry')">
<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">{{ $t('neighbor.strip.noSenders') }}</p>
</section>
<!-- 3) reached% quantile histogram (5 GREEN bins → reached-floor brush) -->
<section class="cs-card">
<header>
<h4>{{ $t('neighbor.strip.quantile') }}</h4>
<button v-if="store.facets.value.reachedMin != null" class="cs-clr" @click="store.setReachedMin(null)">{{ $t('neighbor.strip.clear') }}</button>
</header>
<svg class="cs-svg" :viewBox="'0 0 ' + HIST_VB_W + ' ' + HIST_VB_H"
preserveAspectRatio="xMidYMid meet" role="img" :aria-label="$t('neighbor.strip.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' ? $t('neighbor.strip.religionFamily') : $t('neighbor.strip.bridgeType') }}</h4>
<button v-if="store.facets.value[donutKind].size" class="cs-clr"
@click="store.facets.value[donutKind].forEach(v => store.toggleFacet(donutKind, v))">{{ $t('neighbor.strip.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="$t('neighbor.strip.donut')">
<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">{{ $t('neighbor.strip.noSenders') }}</p>
</section>
</div>
</div>
</template>

View file

@ -1,408 +0,0 @@
<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">{{ $t('neighbor.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">
{{ $t('neighbor.flower.pickTarget') }}<br>{{ $t('neighbor.flower.bloomsHere') }}
</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">
{{ $t('neighbor.fm.whorl') }}<br>
{{ $t('neighbor.cockpit.navySend') }} · <span :style="{ color: COLORS.GOLD }">{{ $t('neighbor.cockpit.goldMesh') }}</span>
</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

@ -1,371 +0,0 @@
<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 { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { labelFont } from '@map/maps/label-font.js'
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'
import { useNeighborT } from '../composables/useNeighborT.js'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from '../whenStyleReady.js'
const nt = useNeighborT()
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(nt('neighbor.fc.clustering'))
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: { ...labelFont(m), '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()
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(j.posts || j.data || j.records || j.results || (Array.isArray(j) ? j : []), cfg.value)
// 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
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
try { doxaFeatures.value = await fetchDoxa() }
catch (e) { statusMsg.value = nt('neighbor.lc.apiDown', { message: 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', tKey: 'neighbor.fc.mode.family', szKey: 'neighbor.fc.size.fewerBigger' },
{ key: 'language', tKey: 'neighbor.fc.mode.language', szKey: 'neighbor.fc.size.more' },
{ key: 'dialect', tKey: 'neighbor.fc.mode.dialect', szKey: 'neighbor.fc.size.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="$t('neighbor.fc.clusterBy')">
<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.key.slice(0,3).toUpperCase() }}</span>
<span class="fc-mode-lbl">{{ $t(md.tKey) }}</span>
<span class="fc-mode-sz">{{ $t(md.szKey) }}</span>
</button>
</div>
<div v-if="appReady" class="fc-stats">
{{ $t('neighbor.fc.flowers') }}: <b>{{ stats.cores }}</b> · {{ $t('neighbor.fc.unreached') }}: <b>{{ stats.unreached.toLocaleString() }}</b><br>
<b>{{ stats.petals.toLocaleString() }}</b> reached petals · by {{ mode }}
</div>
<div v-if="appReady" class="fc-legend">
<h4>{{ $t('neighbor.fc.legendTitle') }}</h4>
<div class="fc-lrow"><span class="fc-dot" :style="{ background: C_RED }"></span> {{ $t('neighbor.fc.legendCore') }}</div>
<div class="fc-lrow"><span class="fc-dot" :style="{ background: reachedColor(80) }"></span> {{ $t('neighbor.fc.legendPetal') }}</div>
<div class="fc-lrow" style="color:#6b7280;font-size:10px;margin-top:4px">{{ $t('neighbor.fc.legendHelp') }}</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

@ -1,402 +0,0 @@
<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'
import { useNeighborT } from '../composables/useNeighborT.js'
const nt = useNeighborT()
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', tKey: 'neighbor.list.sortMobilization' },
{ key: 'pct', tKey: 'neighbor.list.colPctChristian' },
{ key: 'pop', tKey: 'neighbor.list.colPopulation' },
{ key: 'bridge', tKey: 'neighbor.strip.bridgeType' },
{ key: 'name', tKey: 'neighbor.list.colName' },
{ key: 'frontier', tKey: 'neighbor.list.sortFrontier' }
]
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 = nt('neighbor.list.csvDownloaded', { n: 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 ? nt('neighbor.list.copied', { n: list.length }) : nt('neighbor.list.copyFailed')
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">
{{ $t('neighbor.list.sendersAvailable', { n: availableTotal.toLocaleString() }) }}
</span>
<span v-if="hiddenCount" class="cpt-l-hidden">{{ $t('neighbor.list.hidden', { n: hiddenCount.toLocaleString() }) }}</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)"
>{{ $t(s.tKey) }}</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" />
{{ $t('neighbor.list.buildSendList') }}
<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">{{ $t('neighbor.list.clear') }}</button>
<button class="cpt-l-btn" type="button" :disabled="!exportRows.length" @click="copyList">{{ $t('neighbor.list.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="$t('neighbor.cockpit.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>{{ $t('neighbor.list.colPeopleGroup') }}</dt><dd>{{ r.name }}</dd>
<dt>{{ $t('neighbor.list.colCountry') }}</dt><dd>{{ r.country }}</dd>
<dt>{{ $t('neighbor.list.colLanguage') }}</dt><dd>{{ r.lang }}<span v-if="r.rol3"> · {{ r.rol3 }}</span></dd>
<dt>{{ $t('neighbor.list.colPctChristian') }}</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
<dt>{{ $t('neighbor.list.colPopulation') }}</dt><dd>{{ fmtPop(r.pop) }}</dd>
<dt>{{ $t('neighbor.strip.bridgeType') }}</dt><dd>{{ r.bridge }}</dd>
<dt>{{ $t('neighbor.list.colAdjacency') }}</dt><dd>{{ r.adjacency }}</dd>
<dt>{{ $t('neighbor.list.colReligion') }}</dt><dd>{{ r.religion_bg || r.religionFamily }}</dd>
<dt v-if="r.code">{{ $t('neighbor.list.colCode') }}</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">{{ $t('neighbor.list.noSenders') }}</div>
<div class="sm" v-if="hiddenCount">{{ $t('neighbor.list.sendersHidden', { n: hiddenCount.toLocaleString() }) }}</div>
<div class="sm" v-else>{{ $t('neighbor.list.noReachedYet') }}</div>
</div>
<!-- placeholder: nothing focused -->
<div class="cpt-l-empty" v-else>
<div class="big">{{ $t('neighbor.list.pickTarget') }}</div>
<div class="sm">{{ $t('neighbor.list.clustersNearby', { c: counts.clusters.toLocaleString(), s: counts.senders.toLocaleString() }) }}</div>
<div class="sm">{{ $t('neighbor.list.tapBubble') }}</div>
</div>
</div>
</template>

View file

@ -1,485 +0,0 @@
<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 { labelFont } from '@map/maps/label-font.js'
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'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from '../whenStyleReady.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: { ...labelFont(m), '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: { ...labelFont(m), '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
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
// 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="$t('neighbor.mc.greenEncoding')">
<button :class="{ on: store.colorBy.value === 'pct' }" @click="store.setColorBy('pct')">{{ $t('neighbor.strip.pctReached') }}</button>
<button :class="{ on: store.colorBy.value === 'religion' }" @click="store.setColorBy('religion')">{{ $t('neighbor.strip.religion') }}</button>
</div>
<button class="cpt-mc-btn" :class="{ on: store.provinceDrill.value }" @click="store.toggleProvince()"
:title="$t('neighbor.mc.provincesTitle')">{{ $t('neighbor.mc.provinces') }}</button>
<button v-if="store.focusedTarget.value" class="cpt-mc-btn gold" @click="store.clearFocus()">{{ $t('neighbor.mc.resetFocus') }}</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">{{ $t('neighbor.mc.needsToken') }}</span>
<span v-else>{{ $t('neighbor.mc.rendering') }}</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

@ -1,570 +0,0 @@
/**
* 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 { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
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()
// Only the groups of this page's people-group LIST (library/lists/).
return applyPeopleGroupList(j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : []), cfg)
}
/** 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

@ -1,517 +0,0 @@
<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">${nt('neighbor.reached')}</span> ${nt('neighbor.pctChristian', { n: Math.round(n.pct_christian || 0) })}</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">${nt('neighbor.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'
import { useNeighborT } from './composables/useNeighborT.js'
const nt = useNeighborT()
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 = nt('neighbor.fm.loadError')
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">{{ $t('neighbor.title.flowerMesh') }}</span>
<select v-if="targetNames.length" class="fm-select"
:value="activeTarget" @change="onSelectTarget"
:aria-label="$t('neighbor.selectTarget')">
<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> {{ $t('neighbor.fm.languageMesh') }}
</span>
<span v-if="targetRec" class="fm-stats">
{{ $t('neighbor.fm.reachedNeighbors') }}: <b>{{ stats.total }}</b> · {{ $t('neighbor.fm.countries') }}: <b>{{ stats.countries }}</b><br>
{{ $t('neighbor.fm.upgsInCenter') }}: <b>{{ fmt(center.upg) }}</b> · {{ $t('neighbor.fm.meshLinks') }}: <b>{{ stats.meshes }}</b>
</span>
</div>
<!-- the flower -->
<div class="fm-stage">
<div v-if="!appReady" class="fm-loading">{{ $t('neighbor.fm.growing') }}</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 }} — {{ $t('neighbor.pctChristian', { n: Math.round(p.node.pct_christian || 0) }) }}, {{ 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">{{ $t('neighbor.fm.upgs', { n: fmt(center.upg) }) }}</text>
<text class="fm-center-sub" :x="CX" :y="CY + 30" style="opacity:.85">{{ $t('neighbor.fm.people', { n: fmtPop(center.pop) }) }}</text>
</g>
</svg>
<!-- legend -->
<div v-if="appReady && !loadError" class="fm-legend">
<h4>{{ $t('neighbor.fm.legendTitle') }}</h4>
<div class="fm-lrow"><span class="fm-dot" :style="{ background: C_ORANGE }"></span> {{ $t('neighbor.fm.unreachedTarget') }}</div>
<div class="fm-lrow"><span class="fm-dot" :style="{ background: reachedColor(90) }"></span> {{ $t('neighbor.fm.reachedDeeper') }}</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#5b7fd0"></span> {{ $t('neighbor.fm.sendPath') }}</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#3a4f86;border-top-style:dashed"></span> {{ $t('neighbor.fm.viaCluster') }}</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#ffae66"></span> {{ $t('neighbor.fm.mesh') }}</div>
<div class="fm-lrow" style="color:#7e90b6;margin-top:5px;font-size:10px">{{ $t('neighbor.fm.whorl') }}</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

@ -1,585 +0,0 @@
<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 { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { labelFont } from '@map/maps/label-font.js'
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'
}
// The lens's translator, defined BEFORE anything in setup calls it: statusMsg below does, and a
// `const` read before its line is a TDZ ReferenceError that throws setup and leaves no map at all.
import { useNeighborT } from './composables/useNeighborT.js'
const nt = useNeighborT()
// ─── 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(nt('neighbor.lc.loading'))
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: { ...labelFont(m), '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'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
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()
// Only the groups of this page's people-group LIST (library/lists/).
return applyPeopleGroupList(j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : []), cfg.value)
}
onMounted(async () => {
await initializeMap()
const m = map.value
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
// 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 = nt('neighbor.lc.apiDown', { message: 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">${nt('neighbor.unreached')}</span> ${nt('neighbor.groups', { n: p.count })}</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">${nt('neighbor.reached')}</span> ${nt('neighbor.pctChristian', { n: Math.round(p.pct) })}</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">{{ $t('neighbor.title.langClusters') }}</span>
<span v-if="appReady" class="lc-stats">
{{ $t('neighbor.lc.clusters') }}: <b>{{ stats.clusters }}</b> · {{ $t('neighbor.lc.pgs') }}: <b>{{ stats.pgs }}</b><br>
{{ $t('neighbor.lc.provinces') }}: <b>{{ stats.provinces }}</b> · {{ $t('neighbor.lc.withReach') }}: <b>{{ stats.withReach }}</b>
</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> {{ $t('neighbor.lc.legendCluster') }}</div>
<div class="lc-lrow"><span class="lc-dot" :style="{ background: reachedColor(80) }"></span> {{ $t('neighbor.lc.legendReached') }}</div>
<div class="lc-lrow" style="color:#6b7280;font-size:10px;margin-top:4px">{{ $t('neighbor.lc.legendHelp') }}</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">{{ $t('neighbor.lc.setCount', { n: selected.count }) }}</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">{{ $t('neighbor.lc.more', { n: selected.members.length - 18 }) }}</span>
<div class="lc-sec" style="margin-top:10px">{{ $t('neighbor.lc.reachedSame', { n: selected.reached.length }) }}</div>
<div v-if="!selected.reached.length" class="lc-muted">{{ $t('neighbor.lc.frontier') }}</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 }} · {{ $t('neighbor.pctChristian', { n: Math.round(r.pct) }) }} · {{ r.lang }}</div>
</div>
<button class="lc-drill" @click="drillToFlower">{{ $t('neighbor.lc.openFlower') }}</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,300 @@
<script setup>
/**
* mobilize.vue — pick an unreached people group; see the reached groups whose believers are nearest
* to it in culture (Winter's E-scale) and geography, and export them as campaign audience segments.
*
* The question it answers: "for THIS unreached group, whom do we mobilize, in what language, where,
* and how many believers are there?" Data: nearData.js (data/tables/near-neighbors.json).
* The panel works without the map: if the map cannot be built, search and the brief still do.
*/
import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
import { useNeighborT } from './composables/useNeighborT.js'
import { loadNearData, E_COLORS, UNREACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv } from './nearData.js'
const nt = useNeighborT()
// Full literal keys (the i18n gate reads literals; a key built by concatenation looks missing to it).
const E_SHORT = { 1: 'neighbor.e.short1', 2: 'neighbor.e.short2', 3: 'neighbor.e.short3' }
const E_TITLE = { 1: 'neighbor.e.title1', 2: 'neighbor.e.title2', 3: 'neighbor.e.title3' }
const E_LONG = { 1: 'neighbor.e.long1', 2: 'neighbor.e.long2', 3: 'neighbor.e.long3' }
const P_TITLE = { 0: 'neighbor.p.title0', 1: 'neighbor.p.title1', 2: 'neighbor.p.title2', 3: 'neighbor.p.title3' }
const P_LONG = { 0: 'neighbor.p.long0', 1: 'neighbor.p.long1', 2: 'neighbor.p.long2', 3: 'neighbor.p.long3' }
const profileConfig = inject('profileConfig', ref({}))
const mapboxToken = inject('mapboxToken', ref(''))
const nbState = inject('nbState') // shared with the root: selected group, active view
const cfg = computed(() => profileConfig?.value || {})
const mapContainer = ref(null)
const data = ref(null)
const loadError = ref('')
const mapError = ref('')
const query = ref('')
const pFilter = ref(0) // 0 = all, else only groups at that P-level
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 ?? [45, 20],
zoom: cfg.value.zoom ?? 1.6,
minZoom: 1, maxZoom: 11,
})
// ─── Selection ────────────────────────────────────────────────────────────────
const selected = computed(() => data.value?.groups.find((g) => g.pgid === nbState.pgid && !g.reached) || null)
const neighbors = computed(() => (selected.value ? data.value.neighborsOf(selected.value) : []))
const byLevel = computed(() => [1, 2, 3].map((e) => ({ e, list: neighbors.value.filter((n) => n.e === e) })).filter((x) => x.list.length))
const believersNear = computed(() => neighbors.value.reduce((a, n) => a + n.g.believers, 0))
function select(g) { if (g && !g.reached) { nbState.pgid = g.pgid; query.value = '' } }
function clearSelection() { nbState.pgid = null }
// ─── Search / default list ───────────────────────────────────────────────────
const pool = computed(() => (data.value ? data.value.unreached.filter((u) => !pFilter.value || u.p === pFilter.value) : []))
const results = computed(() => {
const q = fold(query.value.trim())
const list = q
? pool.value.filter((u) => fold(u.name).includes(q) || fold(u.country).includes(q) || fold(u.langName).includes(q))
: pool.value
return [...list].sort((a, b) => b.pop - a.pop).slice(0, 40)
})
const pCounts = computed(() => {
const c = { 1: 0, 2: 0, 3: 0 }
for (const u of data.value?.unreached || []) if (c[u.p] != null) c[u.p]++
return c
})
// ─── Export: one row per neighbor = one audience segment ────────────────────
function exportSegments() {
const u = selected.value
if (!u) return
const header = ['unreached_group', 'unreached_country', 'unreached_language', 'unreached_population', 'p_level',
'e_level', 'same_people', 'neighbor_group', 'neighbor_country', 'campaign_language', 'campaign_language_code',
'est_believers', 'distance_km', 'lat', 'lng', 'bible', 'jesus_film', 'audio', 'radio', 'pgid']
const rows = neighbors.value.map((n) => [u.name, u.country, u.langName, u.pop, 'P-' + u.p, 'E-' + n.e, n.same ? 'yes' : '',
n.g.name, n.g.country, n.g.langName, n.g.lang, n.g.believers, n.km, n.g.lat, n.g.lng,
n.g.bible ? 'yes' : 'no', n.g.film ? 'yes' : 'no', n.g.audio ? 'yes' : 'no', n.g.radio ? 'yes' : 'no', n.g.pgid])
downloadCsv(`near-neighbors-${u.pgid}.csv`, header, rows)
}
function openBridge() { if (selected.value) { nbState.lang = selected.value.lang; nbState.view = 'campaigns' } }
const isBridge = computed(() => !!selected.value && !!data.value?.bridges.some((b) => b.lang === selected.value.lang))
// ─── Map ──────────────────────────────────────────────────────────────────────
const SRC_U = 'nb-u', SRC_N = 'nb-n', SRC_L = 'nb-l', SRC_S = 'nb-s'
const fc = (features) => ({ type: 'FeatureCollection', features })
const pt = (g, props) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] }, properties: props })
function unreachedFeatures() {
return fc(pool.value.map((u) => pt(u, { id: u.pgid, pop: u.pop })))
}
function selectionFeatures() {
const u = selected.value
if (!u) return { n: fc([]), l: fc([]), s: fc([]) }
const n = [], l = []
for (const x of neighbors.value) {
n.push(pt(x.g, { id: x.g.pgid, e: x.e, name: x.g.name, color: E_COLORS[x.e] }))
l.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: [[x.g.lng, x.g.lat], [u.lng, u.lat]] }, properties: { e: x.e, color: E_COLORS[x.e] } })
}
return { n: fc(n), l: fc(l), s: fc([pt(u, { name: u.name })]) }
}
function setSrc(m, id, d) { const s = m.getSource(id); if (s) s.setData(d); else m.addSource(id, { type: 'geojson', data: d }) }
function ensureLayers(m) {
setSrc(m, SRC_U, unreachedFeatures())
const sel = selectionFeatures()
setSrc(m, SRC_L, sel.l); setSrc(m, SRC_N, sel.n); setSrc(m, SRC_S, sel.s)
if (!m.getLayer('nb-u')) m.addLayer({ id: 'nb-u', type: 'circle', source: SRC_U, paint: {
'circle-color': UNREACHED,
'circle-radius': ['interpolate', ['linear'], ['ln', ['max', ['get', 'pop'], 1]], 6, 1.6, 12, 3.2, 16, 6.5, 19, 10],
'circle-opacity': 0.55, 'circle-stroke-width': 0.4, 'circle-stroke-color': '#fff' } })
if (!m.getLayer('nb-l')) m.addLayer({ id: 'nb-l', type: 'line', source: SRC_L, layout: { 'line-cap': 'round' },
paint: { 'line-color': ['get', 'color'], 'line-width': ['match', ['get', 'e'], 1, 2.2, 2, 1.6, 1.1], 'line-opacity': 0.75 } })
if (!m.getLayer('nb-n')) m.addLayer({ id: 'nb-n', type: 'circle', source: SRC_N, paint: {
'circle-color': ['get', 'color'], 'circle-radius': ['match', ['get', 'e'], 1, 7, 2, 6, 5],
'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } })
if (!m.getLayer('nb-s')) m.addLayer({ id: 'nb-s', type: 'circle', source: SRC_S, paint: {
'circle-color': UNREACHED, 'circle-radius': 10, 'circle-stroke-width': 3, 'circle-stroke-color': SELECTED } })
applyDim(m)
}
function applyDim(m) {
if (m.getLayer('nb-u')) m.setPaintProperty('nb-u', 'circle-opacity', selected.value ? 0.18 : 0.55)
}
function repaint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
ensureLayers(m)
}
function frame() {
const m = map.value, u = selected.value
if (!m || !u) return
const pts = [[u.lng, u.lat], ...neighbors.value.map((x) => [x.g.lng, x.g.lat])]
const lngs = pts.map((p) => p[0]), lats = pts.map((p) => p[1])
try {
m.stop()
if (pts.length < 2) m.easeTo({ center: [u.lng, u.lat], zoom: 4, duration: 800 })
else m.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
{ padding: framePadding(), maxZoom: 6, duration: 900 })
} catch (_) { /* a camera call on a map mid-teardown is not worth an error */ }
}
// The panel covers part of the map: the left 400px on wide screens, the bottom 55% on a phone.
// Frame the selection in the part you can see.
function framePadding() {
const el = mapContainer.value, w = el?.clientWidth || 0, h = el?.clientHeight || 0
return w > 760 ? { top: 60, bottom: 60, left: 400, right: 60 } : { top: 40, bottom: Math.round(h * 0.55) + 20, left: 30, right: 30 }
}
watch(() => [nbState.pgid, pFilter.value, data.value], () => { repaint(); frame() })
onMounted(async () => {
loadNearData(profileConfig).then((d) => { data.value = d; repaint(); frame() })
.catch((e) => { loadError.value = nt('neighbor.mob.loadError', { message: String(e.message || e) }) })
await initializeMap()
const m = map.value
if (!m) { mapError.value = nt('messages.mapUnavailable'); return }
whenStyleReady(m, () => {
setProjectionOn(m, 'mercator')
repaint(); frame()
m.on('click', 'nb-u', (ev) => {
const id = ev.features?.[0]?.properties?.id
const g = data.value?.unreached.find((u) => u.pgid === id)
if (g) select(g)
})
for (const id of ['nb-u', 'nb-n']) {
m.on('mouseenter', id, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = '' })
}
})
// A later style reload (a theme switch) drops our layers; put them back.
m.on('style.load', repaint)
})
onBeforeUnmount(() => destroy())
useShadowStyles(`
.mb-root { position:absolute; inset:0; display:flex; background:#f6f7f9; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; color:#1f2937; }
.mb-map { position:absolute; inset:0; }
.mb-panel { position:absolute; top:10px; left:10px; bottom:10px; width:380px; z-index:20; display:flex; flex-direction:column;
background:#fff; border:1px solid #d9dee6; border-radius:12px; box-shadow:0 6px 20px rgba(0,20,60,.12); overflow:hidden; }
.mb-head { padding:12px 14px 10px; border-bottom:1px solid #eef0f4; }
.mb-search { width:100%; box-sizing:border-box; border:1px solid #c9d1dd; border-radius:8px; padding:9px 11px; font:14px/1.2 inherit; }
.mb-search:focus { outline:2px solid ${NAVY}; outline-offset:1px; }
.mb-pchips { display:flex; gap:6px; margin-top:8px; flex-wrap:wrap; }
.mb-chip { border:1px solid #c9d1dd; background:#fff; color:#374151; border-radius:999px; padding:4px 10px; font:600 12px/1 inherit; cursor:pointer; }
.mb-chip.on { background:${NAVY}; border-color:${NAVY}; color:#fff; }
.mb-body { flex:1 1 0; overflow-y:auto; }
.mb-row { display:flex; align-items:center; gap:10px; width:100%; text-align:left; border:0; border-bottom:1px solid #f1f3f6; background:#fff; padding:9px 14px; cursor:pointer; font:inherit; color:inherit; }
.mb-row:hover, .mb-row:focus-visible { background:#f5f8ff; }
.mb-row-main { flex:1 1 auto; min-width:0; }
.mb-row-name { font:600 13px/1.25 inherit; }
.mb-row-sub { font:12px/1.3 inherit; color:#6b7280; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mb-pbadge { flex:0 0 auto; font:700 11px/1 ui-monospace,monospace; border-radius:6px; padding:4px 6px; color:#fff; }
.mb-brief { padding:12px 14px 16px; }
.mb-back { border:0; background:none; color:${NAVY}; font:600 12px/1 inherit; cursor:pointer; padding:0 0 8px; }
.mb-title { font:700 17px/1.2 inherit; margin:0; }
.mb-sub { color:#4b5563; font-size:13px; margin:4px 0 10px; }
.mb-p { border-radius:10px; padding:10px 12px; margin-bottom:10px; color:#0b2e1a; font-size:13px; line-height:1.4; }
.mb-p strong { display:block; font-size:14px; margin-bottom:2px; }
.mb-res { display:flex; flex-wrap:wrap; gap:6px; margin:0 0 12px; }
.mb-res span { font:600 11px/1 inherit; border-radius:999px; padding:4px 8px; border:1px solid #d1d5db; color:#6b7280; }
.mb-res span.yes { border-color:#9fd3b0; color:#0b5e33; background:#eaf7ef; }
.mb-actions { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.mb-btn { border:1px solid ${NAVY}; background:${NAVY}; color:#fff; border-radius:8px; padding:8px 11px; font:600 12px/1 inherit; cursor:pointer; }
.mb-btn.ghost { background:#fff; color:${NAVY}; }
.mb-level { margin-top:12px; }
.mb-level h3 { display:flex; align-items:center; gap:8px; font:700 13px/1.2 inherit; margin:0 0 4px; }
.mb-level p { margin:0 0 6px; color:#6b7280; font-size:12px; line-height:1.35; }
.mb-dot { width:11px; height:11px; border-radius:50%; flex:0 0 auto; }
.mb-card { border:1px solid #e5e7eb; border-radius:8px; padding:8px 10px; margin-bottom:6px; font-size:12px; line-height:1.35; }
.mb-card b { font-size:13px; }
.mb-card .muted { color:#6b7280; }
.mb-same { font:700 10px/1 inherit; color:#7a3e00; background:#fdeedd; border-radius:999px; padding:3px 6px; margin-left:4px; }
.mb-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
.mb-maperr { position:absolute; right:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
.mb-legend { position:absolute; right:12px; bottom:28px; z-index:15; background:rgba(255,255,255,.95); border:1px solid #d9dee6; border-radius:10px; padding:8px 10px; font-size:12px; }
.mb-legend div { display:flex; align-items:center; gap:7px; padding:2px 0; }
@media (max-width:760px) {
.mb-panel { top:auto; left:6px; right:6px; bottom:6px; width:auto; height:55%; }
.mb-legend { top:8px; bottom:auto; right:8px; }
}
`, 'upgn-mobilize')
</script>
<template>
<div class="mb-root">
<div ref="mapContainer" class="mb-map"></div>
<div v-if="mapError" class="mb-maperr" role="status">{{ mapError }}</div>
<div class="mb-legend" :aria-label="$t('neighbor.mob.legend')">
<div><span class="mb-dot" :style="{ background: UNREACHED }"></span>{{ $t('neighbor.mob.legendUnreached') }}</div>
<div v-for="e in [1, 2, 3]" :key="e"><span class="mb-dot" :style="{ background: E_COLORS[e] }"></span>{{ $t(E_SHORT[e]) }}</div>
</div>
<aside class="mb-panel">
<template v-if="!selected">
<div class="mb-head">
<input v-model="query" class="mb-search" type="search" :placeholder="$t('neighbor.mob.searchPlaceholder')" :aria-label="$t('neighbor.mob.searchPlaceholder')" />
<div class="mb-pchips" role="group" :aria-label="$t('neighbor.mob.pFilter')">
<button class="mb-chip" :class="{ on: pFilter === 0 }" @click="pFilter = 0">{{ $t('neighbor.mob.pAll') }}</button>
<button v-for="p in [1, 2, 3]" :key="p" class="mb-chip" :class="{ on: pFilter === p }" :title="$t(P_LONG[p])"
@click="pFilter = p">P-{{ p }} · {{ (pCounts[p] || 0).toLocaleString() }}</button>
</div>
</div>
<div class="mb-body">
<div v-if="loadError" class="mb-note" role="alert">{{ loadError }}</div>
<div v-else-if="!data" class="mb-note">{{ $t('neighbor.mob.loading') }}</div>
<template v-else>
<div v-if="!query" class="mb-note" style="padding-bottom:6px">{{ $t('neighbor.mob.intro') }}</div>
<button v-for="u in results" :key="u.pgid" class="mb-row" @click="select(u)">
<span class="mb-pbadge" :style="{ background: E_COLORS[u.p] || '#9aa7b4' }">P-{{ u.p || '–' }}</span>
<span class="mb-row-main">
<span class="mb-row-name">{{ u.name }}</span>
<span class="mb-row-sub" style="display:block">{{ u.country }} · {{ u.langName }} · {{ fmtNum(u.pop) }}</span>
</span>
</button>
<div v-if="!results.length" class="mb-note">{{ $t('neighbor.mob.noMatch') }}</div>
</template>
</div>
</template>
<div v-else class="mb-body">
<div class="mb-brief">
<button class="mb-back" @click="clearSelection">← {{ $t('neighbor.mob.back') }}</button>
<h2 class="mb-title">{{ selected.name }}</h2>
<div class="mb-sub">{{ selected.country }} · {{ selected.langName }} · {{ $t('neighbor.mob.people', { n: fmtNum(selected.pop) }) }} · {{ selected.religion }}</div>
<div class="mb-p" :style="{ background: (E_COLORS[selected.p] || '#9aa7b4') + '22' }">
<strong><template v-if="selected.p">P-{{ selected.p }} · </template>{{ $t(P_TITLE[selected.p]) }}</strong>
{{ $t(P_LONG[selected.p]) }}
<div style="margin-top:4px">{{ $t('neighbor.mob.believersNear', { n: fmtNum(believersNear) }) }}</div>
</div>
<div class="mb-res" :aria-label="$t('neighbor.mob.resources')">
<span :class="{ yes: selected.bible }">{{ $t('neighbor.res.bible') }}</span>
<span :class="{ yes: selected.film }">{{ $t('neighbor.res.film') }}</span>
<span :class="{ yes: selected.audio }">{{ $t('neighbor.res.audio') }}</span>
<span :class="{ yes: selected.radio }">{{ $t('neighbor.res.radio') }}</span>
</div>
<div class="mb-actions">
<button class="mb-btn" @click="exportSegments">{{ $t('neighbor.mob.export') }}</button>
<button v-if="isBridge" class="mb-btn ghost" @click="openBridge">{{ $t('neighbor.mob.openBridge', { lang: selected.langName }) }}</button>
</div>
<section v-for="lv in byLevel" :key="lv.e" class="mb-level">
<h3><span class="mb-dot" :style="{ background: E_COLORS[lv.e] }"></span>{{ $t(E_TITLE[lv.e]) }}</h3>
<p>{{ $t(E_LONG[lv.e]) }}</p>
<div v-for="n in lv.list" :key="n.g.pgid" class="mb-card">
<b>{{ n.g.name }}</b><span v-if="n.same" class="mb-same">{{ $t('neighbor.mob.samePeople') }}</span><br>
{{ n.g.country }} · {{ n.g.langName }}<br>
<span class="muted">{{ $t('neighbor.mob.cardStats', { b: fmtNum(n.g.believers), km: n.km.toLocaleString() }) }}</span>
</div>
</section>
<div v-if="!byLevel.length" class="mb-note">{{ $t('neighbor.mob.noNeighbors') }}</div>
<p class="mb-note" style="padding:12px 0 0">{{ $t('neighbor.mob.caveat') }}</p>
</div>
</div>
</aside>
</div>
</template>

View file

@ -0,0 +1,75 @@
/**
* nearData.js — the near-neighbors dataset (data/tables/near-neighbors.json, built by
* scripts/build-near-neighbors.mjs), fetched ONCE per page and shared by both views.
*
* The file is column-packed and dictionary-encoded to keep it small; decode() turns a row back into
* a plain object once, on load, so the views work with ordinary objects.
*/
import { resolveAssetsBase } from '@map/utils/embedBase.js'
// E-scale colours, one meaning everywhere in this plugin: the nearer the culture, the stronger green.
export const E_COLORS = { 1: '#0f7a3d', 2: '#34a853', 3: '#9aa7b4' }
export const UNREACHED = '#e11d48'
export const SELECTED = '#f59e0b'
export const NAVY = '#002e6e'
let shared = null // one promise per page: switching views never refetches
export function loadNearData(profileConfig) {
if (shared) return shared
const cfg = profileConfig?.value || profileConfig || {}
const url = cfg.nearDataUrl || `${resolveAssetsBase(cfg)}/tables/near-neighbors.json`
shared = fetch(url)
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
.then(decode)
.catch((e) => { shared = null; throw e }) // a failed load may be retried by the next mount
return shared
}
function decode(raw) {
const { cols, dict, rows, links, bridges, meta } = raw
const groups = rows.map((r, i) => {
const g = { i }
cols.forEach((c, j) => { g[c] = dict[c] ? dict[c][r[j]] : r[j] })
g.reached = !!g.reached
return g
})
const neighborsOf = (u) => (links[u.i] || []).map(([ri, e, km, same]) => ({ g: groups[ri], e, km, same: !!same }))
const pLevel = (u) => { const ls = links[u.i]; return ls && ls.length ? Math.min(...ls.map((l) => l[1])) : 0 }
const unreached = groups.filter((g) => !g.reached)
for (const u of unreached) u.p = pLevel(u)
const bridgeList = bridges.map((b) => {
const rs = b.reached.map((i) => groups[i]), us = b.unreached.map((i) => groups[i])
return {
lang: b.lang, langName: rs[0]?.langName || us[0]?.langName || b.lang,
believers: b.believers, unreachedPop: b.unreachedPop,
res: { bible: !!b.res[0], film: !!b.res[1], audio: !!b.res[2], radio: !!b.res[3] },
reached: rs, unreached: us,
reachedCountries: [...new Set(rs.map((g) => g.country))].sort(),
unreachedCountries: [...new Set(us.map((g) => g.country))].sort(),
}
})
return { meta, groups, unreached, neighborsOf, bridges: bridgeList }
}
// Accent-insensitive search text for a group.
export const fold = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
export function fmtNum(n) {
n = Number(n) || 0
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + 'K'
return String(Math.round(n))
}
// CSV download that works from inside a shadow root (the anchor is appended to document.body).
export function downloadCsv(filename, header, rows) {
const cell = (v) => { const s = String(v ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s }
const text = [header, ...rows].map((r) => r.map(cell).join(',')).join('\n') + '\n'
const url = URL.createObjectURL(new Blob(['' + text], { type: 'text/csv;charset=utf-8' }))
const a = document.createElement('a')
a.href = url; a.download = filename; a.style.display = 'none'
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}

View file

@ -1,526 +0,0 @@
<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 { labelFont } from '@map/maps/label-font.js'
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: {
...labelFont(m), '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, ...labelFont(m, { weight: 'bold', mapbox: ['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'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
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
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
// 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">{{ $t('neighbor.loadingSend') }}</div>
<!-- Top: title + target selector -->
<div class="sn-top">
<span class="sn-title">{{ $t('neighbor.title.send') }}</span>
<select v-if="targetNames.length" class="sn-select"
:value="activeTarget" @change="onSelectTarget"
:aria-label="$t('neighbor.selectTarget')">
<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>{{ $t('neighbor.send.unreachedTarget') }} <strong>{{ $t('neighbor.send.clickOne') }}</strong> {{ $t('neighbor.send.toFocus') }}</span></div>
<div class="sn-legend-row"><span class="sn-dot" :style="{ background: C_GREEN }"></span><span>{{ $t('neighbor.send.reachedSender') }}</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">
{{ $t('neighbor.sm.counts', { u: (targetData.target_upg || 0).toLocaleString(), s: senders.length }) }}
</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">{{ $t('neighbor.pctChristian', { n: Math.round(n.pct_christian || 0) }) }}</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;">
{{ $t('neighbor.sm.noSenders') }}
</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,41 +1,40 @@
{
"//": "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.",
"//": "Preset renders of the upg-neighbor profile. The ids are those of the five retired lenses (2026-09-27) so existing embed URLs keep working; each now opens the new view that answers its question: Mobilize (an unreached group's near reached neighbors on the E-scale) or Campaigns (bridge languages).",
"profile": "upg-neighbor",
"instances": [
{
"id": "send-map",
"title": "Send Map",
"title": "Mobilize near neighbors",
"props": {
"view": "send-map"
"view": "mobilize"
}
},
{
"id": "upg-neighbors",
"title": "UPG Neighbors",
"title": "UPG Neighbors: mobilize",
"props": {
"view": "cockpit"
"view": "mobilize"
}
},
{
"id": "lang-cluster",
"title": "Language Clusters",
"title": "Bridge-language campaigns",
"props": {
"view": "lang-cluster"
"view": "campaigns"
}
},
{
"id": "bloom-cockpit",
"title": "Bloom Cockpit",
"title": "Mobilize near neighbors",
"props": {
"view": "cockpit"
"view": "mobilize"
}
},
{
"id": "flower-mesh",
"title": "Flower Mesh",
"opensOnMap": false,
"title": "Mobilize near neighbors",
"props": {
"view": "flower-mesh"
"view": "mobilize"
}
}
]

View file

@ -1,5 +1,8 @@
{
"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."
"config": {
"pinHint": false
},
"note": "Monolithic composite: ONE root .vue with two views over one dataset (data/tables/near-neighbors.json, built by scripts/build-near-neighbors.mjs from peoplegroups.org): Mobilize (an unreached group's E-1/E-2/E-3 reached neighbors, exportable as audience segments) and Campaigns (bridge languages ranked by the unreached population they reach). index.js registers exactly ONE tag (upg-neighbor). The rows of upg-neighbor.instances.json are preset opening views, not separate elements. pinHint:false because the shared coach-mark says 'Tap a coloured dot — someone still needs prayer', which is the prayer map's message, not this tool's, and on a phone it covers the brief."
}

View file

@ -1,112 +1,95 @@
<script setup>
/**
* upg-neighbor.vue — My UPG Neighbors: the five neighbor lenses in one native profile.
* upg-neighbor.vue — My UPG Neighbors: mobilize the reached neighbors nearest to each unreached group.
*
* @description Who goes to whom: reached neighbors matched to unreached people groups, five lenses
* @description Near-neighbor mobilization on Winter's E-scale: who to mobilize, in what language, where
*
* Send Map, the Bloom cockpit, Flower Mesh, Lang Cluster and Flowers behind one view-switcher.
* Pick the opening lens with profile-config 'view'.
* Two views over one dataset (data/tables/near-neighbors.json, built from peoplegroups.org by
* scripts/build-near-neighbors.mjs):
* Mobilize pick an unreached group; see its E-1 / E-2 / E-3 reached neighbors and export them as
* campaign audience segments.
* Campaigns bridge languages: languages spoken by believers AND by unreached groups, ranked by the
* unreached population one campaign in that language can reach.
* @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" }
* profile-config: { "profile":"upg-neighbor", "view":"mobilize"|"campaigns", "pgid":"PG…", "lang":"hin" }
* The five earlier lenses (Send Map, the cockpit, Flower Mesh, Lang Cluster, Flowers) were retired
* 2026-09-27: they ran on a 40-country snapshot and answered one question five ways. Their view names
* still open the nearest new view, so existing embeds keep working.
*/
import { inject, ref, computed, onMounted } from 'vue'
import { inject, reactive, computed, provide } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.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'
import { useNeighborT } from './src/composables/useNeighborT.js'
const nt = useNeighborT()
import Mobilize from './src/mobilize.vue'
import Campaigns from './src/campaigns.vue'
import { NAVY } from './src/nearData.js'
const profileConfig = inject('profileConfig')
const profileConfig = inject('profileConfig', null)
const cfg = computed(() => profileConfig?.value || {})
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)
const VIEWS = ['mobilize', 'campaigns']
// Old view names → the view that now answers their question.
const LEGACY = { 'send-map': 'mobilize', cockpit: 'mobilize', 'upg-neighbors': 'mobilize', 'bloom-cockpit': 'mobilize',
'flower-mesh': 'mobilize', 'lang-cluster': 'campaigns', flowers: 'campaigns' }
const asked = cfg.value.view || cfg.value.legacyElement
const initial = VIEWS.includes(asked) ? asked : (LEGACY[asked] || (cfg.value.lang ? 'campaigns' : 'mobilize'))
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 (_) {}
}
})
// Shared by both views, so a choice made in one is still there in the other.
const nbState = reactive({ view: initial, pgid: cfg.value.pgid || null, lang: cfg.value.lang || null, about: false })
provide('nbState', nbState)
useShadowStyles(`
.dun-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden;
background:#0e1730; isolation:isolate;
.dun-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden; background:#fff; 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; } }
.dun-bar { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:8px 12px; background:${NAVY}; z-index:50; flex-wrap:wrap; }
.dun-brand { font:700 13px/1 inherit; color:#fff; white-space:nowrap; margin-right:4px; }
.dun-seg { display:inline-flex; background:rgba(255,255,255,.08); border-radius:999px; padding:3px; gap:2px; }
.dun-seg button { border:0; background:transparent; color:#c7d3ea; cursor:pointer; font:600 13px/1 inherit; padding:7px 14px; border-radius:999px; }
.dun-seg button.on { background:#fff; color:${NAVY}; }
.dun-seg button:focus-visible, .dun-about:focus-visible { outline:2px solid #fff; outline-offset:2px; }
.dun-about { margin-left:auto; border:1px solid rgba(255,255,255,.35); background:transparent; color:#fff; border-radius:999px; padding:6px 12px; font:600 12px/1 inherit; cursor:pointer; }
.dun-stage { flex:1 1 0; position:relative; min-height:0; }
.dun-modal { position:absolute; inset:0; z-index:60; background:rgba(0,20,50,.45); display:flex; align-items:center; justify-content:center; padding:16px; }
.dun-card { background:#fff; border-radius:12px; max-width:620px; max-height:100%; overflow-y:auto; padding:18px 20px; font-size:14px; line-height:1.5; color:#1f2937; }
.dun-card h2 { margin:0 0 8px; font-size:18px; }
.dun-card h3 { margin:14px 0 4px; font-size:14px; }
.dun-card p { margin:0 0 8px; }
.dun-card .src { font-size:12px; color:#6b7280; }
.dun-close { float:right; border:0; background:none; font-size:20px; line-height:1; cursor:pointer; color:#6b7280; }
@media (max-width:640px){ .dun-brand{ display:none; } .dun-seg button { padding:7px 10px; } }
`, 'upg-neighbor')
const VIEW_KEY = { 'cockpit': 'cockpit', 'send-map': 'sendMap', 'flower-mesh': 'flowerMesh', 'lang-cluster': 'langCluster', 'flowers': 'flowers' }
const activeHint = computed(() => nt('neighbor.hints.' + (VIEW_KEY[view.value] || 'sendMap')))
</script>
<template>
<div ref="rootEl" class="dun-root">
<!-- view-switcher -->
<div class="dun-root">
<div class="dun-bar">
<span class="dun-brand">{{ $t('neighbor.brand') }}</span>
<div class="dun-seg" role="tablist" :aria-label="$t('neighbor.chooseView')">
<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)">{{ $t('neighbor.views.' + VIEW_KEY[v.key]) }}</button>
<button v-for="v in VIEWS" :key="v" role="tab" :aria-selected="nbState.view === v"
:class="{ on: nbState.view === v }" @click="nbState.view = v">{{ $t(v === 'mobilize' ? 'neighbor.views.mobilize' : 'neighbor.views.campaigns') }}</button>
</div>
<span class="dun-hint">{{ activeHint }}</span>
<button class="dun-about" @click="nbState.about = true">{{ $t('neighbor.about.button') }}</button>
</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'" />
<Mobilize v-if="nbState.view === 'mobilize'" />
<Campaigns v-else />
<div v-if="nbState.about" class="dun-modal" role="dialog" aria-modal="true" :aria-label="$t('neighbor.about.title')" @click.self="nbState.about = false">
<div class="dun-card">
<button class="dun-close" :aria-label="$t('neighbor.about.close')" @click="nbState.about = false">×</button>
<h2>{{ $t('neighbor.about.title') }}</h2>
<p>{{ $t('neighbor.about.why') }}</p>
<h3>{{ $t('neighbor.about.eTitle') }}</h3>
<p><b>E-1</b> · {{ $t('neighbor.e.long1') }}<br><b>E-2</b> · {{ $t('neighbor.e.long2') }}<br><b>E-3</b> · {{ $t('neighbor.e.long3') }}</p>
<h3>{{ $t('neighbor.about.pTitle') }}</h3>
<p><b>P-1</b> · {{ $t('neighbor.p.long1') }}<br><b>P-2</b> · {{ $t('neighbor.p.long2') }}<br><b>P-3</b> · {{ $t('neighbor.p.long3') }}</p>
<h3>{{ $t('neighbor.about.dataTitle') }}</h3>
<p>{{ $t('neighbor.about.data') }}</p>
<p class="src">{{ $t('neighbor.about.source') }}</p>
</div>
</div>
</div>
</div>
</template>

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-D5lDxDLe9anngwr6P0fAnEhUxAB6pr/4TdXCicy8fAGAdeNxEAJlp6CrIbcqtwsX" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-UpfV7r8lOCs9c6N6JEfQ/aVn0xv56b4ocMevjQ/+h2k7ehVSjnPhs8C2DLwkb4Xl" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-n0wwrzPqkvguUWfD6L7oFkEFpjpT49CadmrtAZC6Ie0dtmVjAYBlrH1rK8YwwrJe" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-LdpIqKC96OsdMwLS0R8lJ17hr0siPfmIcpsM+WjSdTq8bPicSWNtcNeNNyfmx+44" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-n0wwrzPqkvguUWfD6L7oFkEFpjpT49CadmrtAZC6Ie0dtmVjAYBlrH1rK8YwwrJe" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-LdpIqKC96OsdMwLS0R8lJ17hr0siPfmIcpsM+WjSdTq8bPicSWNtcNeNNyfmx+44" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../demo-map.js" integrity="sha384-ud1IjcfObUtDi/ifchtfE8exMjQ0GeFwm2//GTI5AOiebXZI/oyINUoeqWMbCaF/" crossorigin="anonymous"></script>
<script src="../demo-map.js" integrity="sha384-BzyptX/5BMjBESet3Qam1GKR6ho1cR76SymRCL2Z675awlg/mZPZFYQwDMRAMFxy" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -33,7 +33,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../../demo-map.js" integrity="sha384-ud1IjcfObUtDi/ifchtfE8exMjQ0GeFwm2//GTI5AOiebXZI/oyINUoeqWMbCaF/" crossorigin="anonymous"></script>
<script src="../../demo-map.js" integrity="sha384-BzyptX/5BMjBESet3Qam1GKR6ho1cR76SymRCL2Z675awlg/mZPZFYQwDMRAMFxy" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -12,7 +12,7 @@
"bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "dx-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-D5lDxDLe9anngwr6P0fAnEhUxAB6pr/4TdXCicy8fAGAdeNxEAJlp6CrIbcqtwsX",
"integrity": "sha384-UpfV7r8lOCs9c6N6JEfQ/aVn0xv56b4ocMevjQ/+h2k7ehVSjnPhs8C2DLwkb4Xl",
"elements": [
"all-people-groups"
],
@ -30,7 +30,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-D5lDxDLe9anngwr6P0fAnEhUxAB6pr/4TdXCicy8fAGAdeNxEAJlp6CrIbcqtwsX\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-UpfV7r8lOCs9c6N6JEfQ/aVn0xv56b4ocMevjQ/+h2k7ehVSjnPhs8C2DLwkb4Xl\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
},
"bundle": "all-people-groups"
}
@ -108,7 +108,7 @@
"bundle": "cities-internet",
"scriptUrl": "./cities-internet/cities-internet.js",
"output": "dx-maps/cities-internet/cities-internet.js",
"integrity": "sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW",
"integrity": "sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6",
"elements": [
"cities-internet-map"
],
@ -126,7 +126,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-list/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -141,7 +141,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -156,7 +156,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/data-tables/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
}
]
@ -165,7 +165,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "dx-maps/country-posters/country-posters.js",
"integrity": "sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn",
"integrity": "sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr",
"elements": [
"country-posters-map"
],
@ -187,7 +187,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -202,7 +202,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/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=\"./country-posters/country-posters.js\" integrity=\"sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -217,7 +217,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -226,7 +226,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-n0wwrzPqkvguUWfD6L7oFkEFpjpT49CadmrtAZC6Ie0dtmVjAYBlrH1rK8YwwrJe",
"integrity": "sha384-LdpIqKC96OsdMwLS0R8lJ17hr0siPfmIcpsM+WjSdTq8bPicSWNtcNeNNyfmx+44",
"elements": [
"dashboard-plus-grid"
],
@ -248,7 +248,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-n0wwrzPqkvguUWfD6L7oFkEFpjpT49CadmrtAZC6Ie0dtmVjAYBlrH1rK8YwwrJe\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-LdpIqKC96OsdMwLS0R8lJ17hr0siPfmIcpsM+WjSdTq8bPicSWNtcNeNNyfmx+44\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -272,7 +272,7 @@
"bundle": "demo-map",
"scriptUrl": "./demo-map/demo-map.js",
"output": "dx-maps/demo-map/demo-map.js",
"integrity": "sha384-ud1IjcfObUtDi/ifchtfE8exMjQ0GeFwm2//GTI5AOiebXZI/oyINUoeqWMbCaF/",
"integrity": "sha384-BzyptX/5BMjBESet3Qam1GKR6ho1cR76SymRCL2Z675awlg/mZPZFYQwDMRAMFxy",
"elements": [
"demo-map"
],
@ -290,7 +290,7 @@
"scriptUrl": "./demo-map/demo-map.js",
"embed": {
"iframe": "<iframe src=\"./demo-map/demo-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-ud1IjcfObUtDi/ifchtfE8exMjQ0GeFwm2//GTI5AOiebXZI/oyINUoeqWMbCaF/\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-BzyptX/5BMjBESet3Qam1GKR6ho1cR76SymRCL2Z675awlg/mZPZFYQwDMRAMFxy\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
},
"instances": [
{
@ -371,7 +371,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-rcCpf0xkmBvn0NsK90d7x5Hxk0m0J+rKWCrcCZHpPMI3T9LGUiCh4Rzo7w42/WDK",
"integrity": "sha384-XRdaz1XJcFu2UK8MaZI54lEEqOi/kyoy7rPmGGMxbeNu6BfTyvK6s27oT/3w1N68",
"elements": [
"doxa-research-map"
],
@ -389,7 +389,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-rcCpf0xkmBvn0NsK90d7x5Hxk0m0J+rKWCrcCZHpPMI3T9LGUiCh4Rzo7w42/WDK\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-XRdaz1XJcFu2UK8MaZI54lEEqOi/kyoy7rPmGGMxbeNu6BfTyvK6s27oT/3w1N68\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -415,7 +415,7 @@
"bundle": "doxa-simple-map",
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
"integrity": "sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2",
"integrity": "sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -434,7 +434,7 @@
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
},
"instances": [
{
@ -604,7 +604,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL",
"integrity": "sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz",
"elements": [
"gospel-internet-board"
],
@ -623,7 +623,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{
@ -723,7 +723,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW",
"integrity": "sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU",
"elements": [
"gospel-resources-map"
],
@ -745,7 +745,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -760,7 +760,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -775,7 +775,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -790,7 +790,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-list/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -805,7 +805,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -814,7 +814,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW",
"integrity": "sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4",
"elements": [
"my-upg-100-list-map"
],
@ -836,7 +836,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -851,7 +851,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -866,7 +866,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -995,7 +995,7 @@
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
"integrity": "sha384-E6dQFxFwchdrJ/uozHGiRJTQqN+0RMA7lvNpri38kYOoZXogbOEOkiN1OYvoq3QN",
"integrity": "sha384-MP9V4WJZS2QENIY0/jG9lV8ulokC2vTP4rfK447aXPDtwc/Rucjv0lUpNdMoX5e/",
"elements": [
"pplr-data-maps"
],
@ -1013,7 +1013,7 @@
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"embed": {
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-E6dQFxFwchdrJ/uozHGiRJTQqN+0RMA7lvNpri38kYOoZXogbOEOkiN1OYvoq3QN\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-MP9V4WJZS2QENIY0/jG9lV8ulokC2vTP4rfK447aXPDtwc/Rucjv0lUpNdMoX5e/\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
},
"bundle": "pplr-data-maps"
}
@ -1050,7 +1050,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ",
"integrity": "sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h",
"elements": [
"regions-dashboard"
],
@ -1072,7 +1072,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1087,7 +1087,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/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=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1102,7 +1102,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]
@ -1111,7 +1111,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-pKceSBTsIOyJfeiDZph8zV+9FTPN1eKUsw4FUaTHKCpWGazqrbTCK9lUevp/vppb",
"integrity": "sha384-NXO+WIp3FoLVpHj7LVHb6YEm05dX1Ur/8g4JGbRZ3boDOJy1hnSj6nzvdX50uBO7",
"elements": [
"research-dashboard-map"
],
@ -1129,7 +1129,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-pKceSBTsIOyJfeiDZph8zV+9FTPN1eKUsw4FUaTHKCpWGazqrbTCK9lUevp/vppb\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXO+WIp3FoLVpHj7LVHb6YEm05dX1Ur/8g4JGbRZ3boDOJy1hnSj6nzvdX50uBO7\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}
@ -1231,7 +1231,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM",
"integrity": "sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL",
"elements": [
"upg-neighbor"
],
@ -1241,7 +1241,7 @@
"profile": "upg-neighbor",
"stage": "experimental",
"file": "upg-neighbor.vue",
"description": "Who goes to whom: reached neighbors matched to unreached people groups, five lenses",
"description": "Near-neighbor mobilization on Winter's E-scale: who to mobilize, in what language, where",
"element": "upg-neighbor",
"type": "monolithic",
"isMap": true,
@ -1249,16 +1249,16 @@
"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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL\" 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",
"title": "Mobilize near neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "send-map"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/send-map/index.html",
@ -1268,11 +1268,11 @@
},
{
"id": "upg-neighbors",
"title": "UPG Neighbors",
"title": "UPG Neighbors: mobilize",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "cockpit"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/upg-neighbors/index.html",
@ -1282,11 +1282,11 @@
},
{
"id": "lang-cluster",
"title": "Language Clusters",
"title": "Bridge-language campaigns",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "lang-cluster"
"view": "campaigns"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/lang-cluster/index.html",
@ -1296,11 +1296,11 @@
},
{
"id": "bloom-cockpit",
"title": "Bloom Cockpit",
"title": "Mobilize near neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "cockpit"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/bloom-cockpit/index.html",
@ -1310,18 +1310,17 @@
},
{
"id": "flower-mesh",
"title": "Flower Mesh",
"title": "Mobilize near neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "flower-mesh"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/flower-mesh/index.html",
"embed": {
"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>"
},
"opensOnMap": false
}
}
],
"bundle": "upg-neighbor"
@ -1332,7 +1331,7 @@
"bundle": "wall-of-people-groups",
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"output": "dx-maps/wall-of-people-groups/wall-of-people-groups.js",
"integrity": "sha384-9DA3cSBYXmzEI1y4Tuyp38ilxLTeLZ/H8hpoPQ7TFdLbg2cO8XUzvyB7TYXk2r1A",
"integrity": "sha384-5wAG2q1aIqWtVJYfxzMQLsXwZckC1xTtkQD3xQgNgobmZ4+cCGwqqvSC8AAXpPOJ",
"elements": [
"wall-of-people-groups"
],
@ -1350,7 +1349,7 @@
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-9DA3cSBYXmzEI1y4Tuyp38ilxLTeLZ/H8hpoPQ7TFdLbg2cO8XUzvyB7TYXk2r1A\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-5wAG2q1aIqWtVJYfxzMQLsXwZckC1xTtkQD3xQgNgobmZ4+cCGwqqvSC8AAXpPOJ\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
},
"bundle": "wall-of-people-groups"
}

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-rcCpf0xkmBvn0NsK90d7x5Hxk0m0J+rKWCrcCZHpPMI3T9LGUiCh4Rzo7w42/WDK" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-XRdaz1XJcFu2UK8MaZI54lEEqOi/kyoy7rPmGGMxbeNu6BfTyvK6s27oT/3w1N68" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -33,7 +33,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../../doxa-research-map.js" integrity="sha384-rcCpf0xkmBvn0NsK90d7x5Hxk0m0J+rKWCrcCZHpPMI3T9LGUiCh4Rzo7w42/WDK" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-XRdaz1XJcFu2UK8MaZI54lEEqOi/kyoy7rPmGGMxbeNu6BfTyvK6s27oT/3w1N68" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../doxa-simple-map.js" integrity="sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2" crossorigin="anonymous"></script>
<script src="../doxa-simple-map.js" integrity="sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../gospel-internet.js" integrity="sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL" crossorigin="anonymous"></script>
<script src="../gospel-internet.js" integrity="sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -12,7 +12,7 @@
"bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "dx-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-D5lDxDLe9anngwr6P0fAnEhUxAB6pr/4TdXCicy8fAGAdeNxEAJlp6CrIbcqtwsX",
"integrity": "sha384-UpfV7r8lOCs9c6N6JEfQ/aVn0xv56b4ocMevjQ/+h2k7ehVSjnPhs8C2DLwkb4Xl",
"elements": [
"all-people-groups"
],
@ -30,7 +30,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-D5lDxDLe9anngwr6P0fAnEhUxAB6pr/4TdXCicy8fAGAdeNxEAJlp6CrIbcqtwsX\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-UpfV7r8lOCs9c6N6JEfQ/aVn0xv56b4ocMevjQ/+h2k7ehVSjnPhs8C2DLwkb4Xl\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
},
"bundle": "all-people-groups"
}
@ -108,7 +108,7 @@
"bundle": "cities-internet",
"scriptUrl": "./cities-internet/cities-internet.js",
"output": "dx-maps/cities-internet/cities-internet.js",
"integrity": "sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW",
"integrity": "sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6",
"elements": [
"cities-internet-map"
],
@ -126,7 +126,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-list/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -141,7 +141,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -156,7 +156,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/data-tables/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-cWTrP/EmFfqxKeeSkGvnY1cQwy4uVFqYvRHrZdLlip9mjVjrFdHWEQDCcX3slQRW\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-PZG4YBoVja7c6wLx2MVam5YVFy9h3SGF59LgSf2PeE5eT7z2yMDcmFs4iW3ZHej6\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
}
]
@ -165,7 +165,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "dx-maps/country-posters/country-posters.js",
"integrity": "sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn",
"integrity": "sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr",
"elements": [
"country-posters-map"
],
@ -187,7 +187,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -202,7 +202,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/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=\"./country-posters/country-posters.js\" integrity=\"sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -217,7 +217,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-/KOiWPyDB1TTeKQ/0n47pW4Wzd0hEJQO+Mh0bH3wYAGWpxXwx1+TiS6y30vXv5Qn\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-ED4gn7N1cdIR5W91wnxyaBaQbrt83Ay2WfsqL0LhoRaWfEf6KhKsu+zP7mH1b8nr\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -226,7 +226,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-n0wwrzPqkvguUWfD6L7oFkEFpjpT49CadmrtAZC6Ie0dtmVjAYBlrH1rK8YwwrJe",
"integrity": "sha384-LdpIqKC96OsdMwLS0R8lJ17hr0siPfmIcpsM+WjSdTq8bPicSWNtcNeNNyfmx+44",
"elements": [
"dashboard-plus-grid"
],
@ -248,7 +248,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-n0wwrzPqkvguUWfD6L7oFkEFpjpT49CadmrtAZC6Ie0dtmVjAYBlrH1rK8YwwrJe\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-LdpIqKC96OsdMwLS0R8lJ17hr0siPfmIcpsM+WjSdTq8bPicSWNtcNeNNyfmx+44\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -272,7 +272,7 @@
"bundle": "demo-map",
"scriptUrl": "./demo-map/demo-map.js",
"output": "dx-maps/demo-map/demo-map.js",
"integrity": "sha384-ud1IjcfObUtDi/ifchtfE8exMjQ0GeFwm2//GTI5AOiebXZI/oyINUoeqWMbCaF/",
"integrity": "sha384-BzyptX/5BMjBESet3Qam1GKR6ho1cR76SymRCL2Z675awlg/mZPZFYQwDMRAMFxy",
"elements": [
"demo-map"
],
@ -290,7 +290,7 @@
"scriptUrl": "./demo-map/demo-map.js",
"embed": {
"iframe": "<iframe src=\"./demo-map/demo-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-ud1IjcfObUtDi/ifchtfE8exMjQ0GeFwm2//GTI5AOiebXZI/oyINUoeqWMbCaF/\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-BzyptX/5BMjBESet3Qam1GKR6ho1cR76SymRCL2Z675awlg/mZPZFYQwDMRAMFxy\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
},
"instances": [
{
@ -371,7 +371,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-rcCpf0xkmBvn0NsK90d7x5Hxk0m0J+rKWCrcCZHpPMI3T9LGUiCh4Rzo7w42/WDK",
"integrity": "sha384-XRdaz1XJcFu2UK8MaZI54lEEqOi/kyoy7rPmGGMxbeNu6BfTyvK6s27oT/3w1N68",
"elements": [
"doxa-research-map"
],
@ -389,7 +389,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-rcCpf0xkmBvn0NsK90d7x5Hxk0m0J+rKWCrcCZHpPMI3T9LGUiCh4Rzo7w42/WDK\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-XRdaz1XJcFu2UK8MaZI54lEEqOi/kyoy7rPmGGMxbeNu6BfTyvK6s27oT/3w1N68\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -415,7 +415,7 @@
"bundle": "doxa-simple-map",
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
"integrity": "sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2",
"integrity": "sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -434,7 +434,7 @@
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-dUsM/mr5SltUui/UpPYHxib2wOovYubtD+TLVP5+W1P9w7nUK/+hS87nQokZfrr2\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-NTCkI6p+Ehv5y1Y0eyABnBMdJumMkTyiCWqwUEH14SQvz2sQt8rZPKgzIv0v/Y4O\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
},
"instances": [
{
@ -604,7 +604,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL",
"integrity": "sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz",
"elements": [
"gospel-internet-board"
],
@ -623,7 +623,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-Cc46XlRnEU3rOpr7t5lOIdZ+gQDdh9/UPW33te8TZ5HKpIdVY8QhD3+JABH1N5bL\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-th2kPDmmukrqLom9GGtX4WdtdyUfsEHnnbhecC5WNab2wDJ6iCh1ZLE2Z3nn8Fnz\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{
@ -723,7 +723,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW",
"integrity": "sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU",
"elements": [
"gospel-resources-map"
],
@ -745,7 +745,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -760,7 +760,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -775,7 +775,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -790,7 +790,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-list/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -805,7 +805,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-+6Eg3UjODQS0cmP5uv9IBBpQy0E5x412THMhHtg0G5mDmq8XB94NipaoyvxMuyPW\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-le8HFTops/Qex/thcP8lqvflRNpVv38hsQNuLQg4BNBkmNeY5JfwElBAF61FD+SU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -814,7 +814,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW",
"integrity": "sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4",
"elements": [
"my-upg-100-list-map"
],
@ -836,7 +836,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -851,7 +851,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -866,7 +866,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -995,7 +995,7 @@
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
"integrity": "sha384-E6dQFxFwchdrJ/uozHGiRJTQqN+0RMA7lvNpri38kYOoZXogbOEOkiN1OYvoq3QN",
"integrity": "sha384-MP9V4WJZS2QENIY0/jG9lV8ulokC2vTP4rfK447aXPDtwc/Rucjv0lUpNdMoX5e/",
"elements": [
"pplr-data-maps"
],
@ -1013,7 +1013,7 @@
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"embed": {
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-E6dQFxFwchdrJ/uozHGiRJTQqN+0RMA7lvNpri38kYOoZXogbOEOkiN1OYvoq3QN\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-MP9V4WJZS2QENIY0/jG9lV8ulokC2vTP4rfK447aXPDtwc/Rucjv0lUpNdMoX5e/\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
},
"bundle": "pplr-data-maps"
}
@ -1050,7 +1050,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ",
"integrity": "sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h",
"elements": [
"regions-dashboard"
],
@ -1072,7 +1072,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1087,7 +1087,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/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=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1102,7 +1102,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]
@ -1111,7 +1111,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-pKceSBTsIOyJfeiDZph8zV+9FTPN1eKUsw4FUaTHKCpWGazqrbTCK9lUevp/vppb",
"integrity": "sha384-NXO+WIp3FoLVpHj7LVHb6YEm05dX1Ur/8g4JGbRZ3boDOJy1hnSj6nzvdX50uBO7",
"elements": [
"research-dashboard-map"
],
@ -1129,7 +1129,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-pKceSBTsIOyJfeiDZph8zV+9FTPN1eKUsw4FUaTHKCpWGazqrbTCK9lUevp/vppb\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXO+WIp3FoLVpHj7LVHb6YEm05dX1Ur/8g4JGbRZ3boDOJy1hnSj6nzvdX50uBO7\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}
@ -1231,7 +1231,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM",
"integrity": "sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL",
"elements": [
"upg-neighbor"
],
@ -1241,7 +1241,7 @@
"profile": "upg-neighbor",
"stage": "experimental",
"file": "upg-neighbor.vue",
"description": "Who goes to whom: reached neighbors matched to unreached people groups, five lenses",
"description": "Near-neighbor mobilization on Winter's E-scale: who to mobilize, in what language, where",
"element": "upg-neighbor",
"type": "monolithic",
"isMap": true,
@ -1249,16 +1249,16 @@
"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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL\" 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",
"title": "Mobilize near neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "send-map"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/send-map/index.html",
@ -1268,11 +1268,11 @@
},
{
"id": "upg-neighbors",
"title": "UPG Neighbors",
"title": "UPG Neighbors: mobilize",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "cockpit"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/upg-neighbors/index.html",
@ -1282,11 +1282,11 @@
},
{
"id": "lang-cluster",
"title": "Language Clusters",
"title": "Bridge-language campaigns",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "lang-cluster"
"view": "campaigns"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/lang-cluster/index.html",
@ -1296,11 +1296,11 @@
},
{
"id": "bloom-cockpit",
"title": "Bloom Cockpit",
"title": "Mobilize near neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "cockpit"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/bloom-cockpit/index.html",
@ -1310,18 +1310,17 @@
},
{
"id": "flower-mesh",
"title": "Flower Mesh",
"title": "Mobilize near neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"view": "flower-mesh"
"view": "mobilize"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/flower-mesh/index.html",
"embed": {
"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>"
},
"opensOnMap": false
}
}
],
"bundle": "upg-neighbor"
@ -1332,7 +1331,7 @@
"bundle": "wall-of-people-groups",
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"output": "dx-maps/wall-of-people-groups/wall-of-people-groups.js",
"integrity": "sha384-9DA3cSBYXmzEI1y4Tuyp38ilxLTeLZ/H8hpoPQ7TFdLbg2cO8XUzvyB7TYXk2r1A",
"integrity": "sha384-5wAG2q1aIqWtVJYfxzMQLsXwZckC1xTtkQD3xQgNgobmZ4+cCGwqqvSC8AAXpPOJ",
"elements": [
"wall-of-people-groups"
],
@ -1350,7 +1349,7 @@
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-9DA3cSBYXmzEI1y4Tuyp38ilxLTeLZ/H8hpoPQ7TFdLbg2cO8XUzvyB7TYXk2r1A\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-5wAG2q1aIqWtVJYfxzMQLsXwZckC1xTtkQD3xQgNgobmZ4+cCGwqqvSC8AAXpPOJ\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
},
"bundle": "wall-of-people-groups"
}

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-KSDrkfu7xf49KnvB4C80/gTE7uFfeNpFO0T8uIG7m41w9EaOyIyEFZl4b1ntMAJW" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-Wll+fPvJYo24mMDp9IRrsQlrKOagFR9Ry3CQrFl1LopNNxuJprgpgJzZs+N3Qcq4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
<script src="../pplr-data-maps.js" integrity="sha384-E6dQFxFwchdrJ/uozHGiRJTQqN+0RMA7lvNpri38kYOoZXogbOEOkiN1OYvoq3QN" crossorigin="anonymous"></script>
<script src="../pplr-data-maps.js" integrity="sha384-MP9V4WJZS2QENIY0/jG9lV8ulokC2vTP4rfK447aXPDtwc/Rucjv0lUpNdMoX5e/" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-p58xpd9mTuaBMgLUpAZjwq2HWTQk0qUkq9vkyzchTDjqbNg+k5VG+5oFlxC7pRBZ" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-sgxkZZPgyYWkzDU6pgc5iLfW6/VKxbXskhXCBi1VDl6Zf6FWDTejuqgN07yzpI3h" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-pKceSBTsIOyJfeiDZph8zV+9FTPN1eKUsw4FUaTHKCpWGazqrbTCK9lUevp/vppb" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-NXO+WIp3FoLVpHj7LVHb6YEm05dX1Ur/8g4JGbRZ3boDOJy1hnSj6nzvdX50uBO7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,7 @@
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>Bloom Cockpit · upg-neighbor</title>
<title>Mobilize near neighbors · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
@ -36,7 +36,7 @@
<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-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -52,7 +52,7 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"cockpit"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"mobilize"};
// 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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)

View file

@ -9,7 +9,7 @@
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>Flower Mesh · upg-neighbor</title>
<title>Mobilize near neighbors · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
@ -36,7 +36,7 @@
<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-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -52,7 +52,7 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"flower-mesh"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"mobilize"};
// 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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)

View file

@ -36,7 +36,7 @@
<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-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM" crossorigin="anonymous"></script>
<script src="../upg-neighbor.js" integrity="sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -52,7 +52,7 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"upg-neighbor","dataSource":"pray-tools","instanceId":"upg-neighbor-upg-neighbor","assetsBase":"../../assets"};
var BASE_CONFIG = {"pinHint":false,"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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)

View file

@ -9,7 +9,7 @@
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>Language Clusters · upg-neighbor</title>
<title>Bridge-language campaigns · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
@ -36,7 +36,7 @@
<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-dFjPn00Qj5ZMYjZL0rXtcTFXoL3U8HnUqVMsU8SWbnAzDPlnTxloXF8obCqQH5lM" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-mQcftR3NMkr1fnmdwOglvyTnxUm1l9ThUMBxfOjRuA+DByp/zfMlmYo5a/l8kaSL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -52,7 +52,7 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"lang-cluster"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"campaigns"};
// 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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)

Some files were not shown because too many files have changed in this diff Show more