Every Experimental map carries its own hosted ?, where its controls already are

Map controls: prayer-walk-planner (under fullscreen), gospel-internet (the map and the language
tree, the board's default view), country-posters (the country map, its entry profile) and
all-people-groups get the library HelpButton with hosted. Own bars, where there is no control
column: the Wall (after the Bless/Social toggle), the PPLR hub (the end of its view bar) and the
vision card (its top corner, at the end of the reading direction so Arabic mirrors it) use
useHostedHelp. The vision card's label is the library's own "About this map" in each of the
eleven locales, copied into vision.json rather than translated again. UPG Neighbor is left as it
is: its bar already has an About button that opens the full explainer.

Each "?" stays hidden until a host marks its pane, so nothing changes until the app answers.
This commit is contained in:
Doxa Maps 2026-09-29 01:41:34 +00:00
parent 457d16e708
commit 807c78cc64
20 changed files with 66 additions and 13 deletions

View file

@ -17,5 +17,6 @@
"ruleAll": "كل صف في بيانات IMB لمجموعات الشعوب: مجموعة شعب واحدة في بلد واحد.",
"ruleUnreached": "أولوية الضياع لدى IMB من 0 إلى 2: أقل من 2% إنجيليون.",
"ruleFrontier": "أولوية الضياع لدى IMB رقم 0: أقل من 0.1% إنجيليون. جزء من غير المُبشَّرة.",
"ruleDoxa": "كل مجموعة في قائمة صلاة DOXA، مطابَقة مع بيانات IMB حسب الشعب والبلد. ليست كلها حدودية."
"ruleDoxa": "كل مجموعة في قائمة صلاة DOXA، مطابَقة مع بيانات IMB حسب الشعب والبلد. ليست كلها حدودية.",
"about": "حول هذه الخريطة"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Jede Zeile der IMB-Volksgruppendaten: eine Volksgruppe in einem Land.",
"ruleUnreached": "IMB-Lostness-Priorität 0 bis 2: weniger als 2 % Evangelikale.",
"ruleFrontier": "IMB-Lostness-Priorität 0: weniger als 0,1 % Evangelikale. Eine Teilmenge der Unerreichten.",
"ruleDoxa": "Jede Gruppe auf der DOXA-Gebetsliste, den IMB-Daten nach Volk und Land zugeordnet. Nicht alle sind Frontier."
"ruleDoxa": "Jede Gruppe auf der DOXA-Gebetsliste, den IMB-Daten nach Volk und Land zugeordnet. Nicht alle sind Frontier.",
"about": "Über diese Karte"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Every row of the IMB people-group data: one people group in one country.",
"ruleUnreached": "IMB lostness priority 0 to 2: less than 2% evangelical.",
"ruleFrontier": "IMB lostness priority 0: less than 0.1% evangelical. A subset of the unreached.",
"ruleDoxa": "Every group on the DOXA prayer list, matched to the IMB data by people and country. Not all are frontier."
"ruleDoxa": "Every group on the DOXA prayer list, matched to the IMB data by people and country. Not all are frontier.",
"about": "About this map"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Cada fila de los datos de grupos étnicos del IMB: un grupo étnico en un país.",
"ruleUnreached": "Prioridad de perdición del IMB de 0 a 2: menos del 2 % evangélicos.",
"ruleFrontier": "Prioridad de perdición del IMB 0: menos del 0,1 % evangélicos. Un subconjunto de los no alcanzados.",
"ruleDoxa": "Cada grupo de la lista de oración de DOXA, vinculado a los datos del IMB por pueblo y país. No todos son de frontera."
"ruleDoxa": "Cada grupo de la lista de oración de DOXA, vinculado a los datos del IMB por pueblo y país. No todos son de frontera.",
"about": "Acerca de este mapa"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Chaque ligne des données IMB : un groupe ethnique dans un pays.",
"ruleUnreached": "Priorité de perdition IMB 0 à 2 : moins de 2 % d’évangéliques.",
"ruleFrontier": "Priorité de perdition IMB 0 : moins de 0,1 % d’évangéliques. Un sous-ensemble des non-atteints.",
"ruleDoxa": "Chaque groupe de la liste de prière DOXA, rapproché des données IMB par peuple et pays. Tous ne sont pas de frontière."
"ruleDoxa": "Chaque groupe de la liste de prière DOXA, rapproché des données IMB par peuple et pays. Tous ne sont pas de frontière.",
"about": "À propos de cette carte"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "IMB जनसमूह आँकड़ों की हर पंक्ति: एक देश में एक जनसमूह।",
"ruleUnreached": "IMB लॉस्टनेस प्राथमिकता 0 से 2: 2% से कम इवेंजेलिकल।",
"ruleFrontier": "IMB लॉस्टनेस प्राथमिकता 0: 0.1% से कम इवेंजेलिकल। अप्रचारितों का एक भाग।",
"ruleDoxa": "DOXA प्रार्थना सूची का हर समूह, जनसमूह और देश के आधार पर IMB आँकड़ों से मिलाया गया। सभी सीमांत नहीं हैं।"
"ruleDoxa": "DOXA प्रार्थना सूची का हर समूह, जनसमूह और देश के आधार पर IMB आँकड़ों से मिलाया गया। सभी सीमांत नहीं हैं।",
"about": "इस मानचित्र के बारे में"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Ogni riga dei dati IMB sui gruppi etnici: un gruppo etnico in un paese.",
"ruleUnreached": "Priorità di perdizione IMB da 0 a 2: meno del 2% evangelici.",
"ruleFrontier": "Priorità di perdizione IMB 0: meno dello 0,1% evangelici. Un sottoinsieme dei non raggiunti.",
"ruleDoxa": "Ogni gruppo dell’elenco di preghiera DOXA, abbinato ai dati IMB per popolo e paese. Non tutti sono di frontiera."
"ruleDoxa": "Ogni gruppo dell’elenco di preghiera DOXA, abbinato ai dati IMB per popolo e paese. Non tutti sono di frontiera.",
"about": "Informazioni su questa mappa"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Cada linha dos dados de grupos étnicos do IMB: um grupo étnico em um país.",
"ruleUnreached": "Prioridade de perdição do IMB de 0 a 2: menos de 2% evangélicos.",
"ruleFrontier": "Prioridade de perdição do IMB 0: menos de 0,1% evangélicos. Um subconjunto dos não alcançados.",
"ruleDoxa": "Cada grupo da lista de oração da DOXA, associado aos dados do IMB por povo e país. Nem todos são de fronteira."
"ruleDoxa": "Cada grupo da lista de oração da DOXA, associado aos dados do IMB por povo e país. Nem todos são de fronteira.",
"about": "Sobre este mapa"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Fiecare rând din datele IMB despre grupuri etnice: un grup etnic într-o țară.",
"ruleUnreached": "Prioritatea de pierzare IMB 0–2: sub 2% evanghelici.",
"ruleFrontier": "Prioritatea de pierzare IMB 0: sub 0,1% evanghelici. O submulțime a celor neatinse.",
"ruleDoxa": "Fiecare grup de pe lista de rugăciune DOXA, potrivit cu datele IMB după popor și țară. Nu toate sunt de frontieră."
"ruleDoxa": "Fiecare grup de pe lista de rugăciune DOXA, potrivit cu datele IMB după popor și țară. Nu toate sunt de frontieră.",
"about": "Despre această hartă"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "Каждая строка данных IMB о народах: один народ в одной стране.",
"ruleUnreached": "Приоритет IMB 0–2: менее 2% евангельских христиан.",
"ruleFrontier": "Приоритет IMB 0: менее 0,1% евангельских христиан. Часть недостигнутых.",
"ruleDoxa": "Каждая группа из молитвенного списка DOXA, сопоставленная с данными IMB по народу и стране. Не все из них рубежные."
"ruleDoxa": "Каждая группа из молитвенного списка DOXA, сопоставленная с данными IMB по народу и стране. Не все из них рубежные.",
"about": "Об этой карте"
}

View file

@ -17,5 +17,6 @@
"ruleAll": "IMB 族群数据的每一行:一个国家中的一个族群。",
"ruleUnreached": "IMB 迷失优先级 0 至 2:福音派信徒少于 2%。",
"ruleFrontier": "IMB 迷失优先级 0:福音派信徒少于 0.1%。属于未得之民的一部分。",
"ruleDoxa": "DOXA 祷告名单上的每个族群,按民族和国家与 IMB 数据匹配。并非全部都是前沿族群。"
"ruleDoxa": "DOXA 祷告名单上的每个族群,按民族和国家与 IMB 数据匹配。并非全部都是前沿族群。",
"about": "关于此地图"
}

View file

@ -39,6 +39,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import HelpButton from '@map/components/map-controls/HelpButton.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
useShadowStyles(`
@ -448,6 +449,7 @@ const totalShown = computed(() =>
top offset collapses to the desktop 10px instead of reserving space for one. -->
<MapToolbar :has-geocoder="false">
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
<HelpButton :is-dark="isDark" hosted />
</MapToolbar>
<div v-if="appReady" class="apg-legend">

View file

@ -137,6 +137,7 @@ import LocationButton from '@map/components/map-controls/LocationButton.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vue'
import HamburgerButton from '@map/components/map-controls/HamburgerButton.vue'
import HelpButton from '@map/components/map-controls/HelpButton.vue'
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
@ -2294,6 +2295,7 @@ onBeforeUnmount(() => {
<LocationButton :map="map" :is-dark="isDark" />
<FullscreenButton :map-container="mapContainer" :is-dark="isDark" />
<ThemeToggleButton :is-dark="isDark" @toggle="handleToggleTheme" />
<HelpButton :is-dark="isDark" hosted />
<!-- EXPORT, ALWAYS VISIBLE (KNOWN-BUGS NE-17). "Export poster…" lived only in the side drawer,
whose opener is off by default (hamburgerMenu:false), so no reader could reach it; the
legend's Export menu needs a row selected first. This is the same one-click toolbar control

View file

@ -25,6 +25,7 @@
* reads `profile-config` for optional overrides: title, tagline, accent, locale.
*/
import { computed } from 'vue'
import { useHostedHelp } from '@map/composables/useHostedHelp.js'
import FUNNEL from '../../data/people-groups/vision-funnel.json'
// Every locale's strings, compiled in (library/i18n/locales/<lc>/vision.json, held to key parity by
@ -67,6 +68,8 @@ const people = (n) => new Intl.NumberFormat(locale.value, { notation: 'compact',
const date = (iso) => { try { return new Intl.DateTimeFormat(locale.value, { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(iso + 'T00:00:00Z')) } catch { return iso } }
const accent = computed(() => config.value.accent || '#92b195')
// The card's "?" asks its HOST for this map's help (doxa:help); it shows only where a host answers.
const { hosted: helpHosted, askForHelp } = useHostedHelp()
const heading = computed(() => config.value.title || t('title'))
const tagline = computed(() => config.value.tagline || t('tagline'))
@ -89,6 +92,8 @@ const sources = computed(() => t('sources', {
<template>
<div class="vc-root" :dir="dir" :lang="locale" :style="{ '--accent': accent }">
<article class="vc-card">
<button v-if="helpHosted" type="button" class="vc-help" :aria-label="t('about')" :title="t('about')"
@click="askForHelp($event.currentTarget)">?</button>
<div class="vc-kicker"><span class="vc-mark" aria-hidden="true"></span>{{ t('kicker') }}</div>
<h1 class="vc-title">{{ heading }}</h1>
<p class="vc-tagline">{{ tagline }}</p>
@ -130,6 +135,7 @@ const sources = computed(() => t('sources', {
-webkit-font-smoothing: antialiased;
}
.vc-card {
position: relative;
width: 100%; max-width: 640px; box-sizing: border-box;
background: #fff; border: 1px solid #d1d4cd; border-radius: 16px;
box-shadow: 0 1px 2px rgba(59,70,61,.06), 0 8px 24px -12px rgba(59,70,61,.18);
@ -140,6 +146,12 @@ const sources = computed(() => t('sources', {
font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #6d766e;
}
.vc-mark { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
/* The card's "?": top corner at the END of the reading direction, so Arabic mirrors it. */
.vc-help {
position: absolute; top: 14px; inset-inline-end: 14px; width: 28px; height: 28px; border-radius: 50%;
border: 1px solid #d1d4cd; background: #fff; color: #3b463d; font: 700 15px/1 Georgia, serif; cursor: pointer;
}
.vc-help:focus-visible { outline: 2px solid #3f8f5a; outline-offset: 2px; }
.vc-title { margin: 10px 0 6px; font-size: clamp(22px, 4.2vw, 30px); line-height: 1.15; font-weight: 800; color: #3b463d; }
.vc-tagline { margin: 0 0 22px; font-size: 15px; line-height: 1.5; color: #6d766e; }

View file

@ -34,6 +34,7 @@ import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import HelpButton from '@map/components/map-controls/HelpButton.vue'
import { useBoardState, EMBED_PATH } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { createBoardMap, reducedMotion } from '../boardEngine.js'
@ -378,6 +379,7 @@ useShadowStyles(
<ZoomInButton :map="mapRef" />
<ZoomOutButton :map="mapRef" />
<ShareButton :embed-path="EMBED_PATH" />
<HelpButton hosted />
</MapToolbar>
<div v-if="legendOpen" class="gim-legend">

View file

@ -32,6 +32,7 @@ import { labelFont } from '@map/maps/label-font.js'
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import HelpButton from '@map/components/map-controls/HelpButton.vue'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { createBoardMap, reducedMotion } from '../boardEngine.js'
@ -367,6 +368,7 @@ useShadowStyles(
<MapToolbar v-if="mapRef">
<ZoomInButton :map="mapRef" />
<ZoomOutButton :map="mapRef" />
<HelpButton hosted />
</MapToolbar>
<div v-if="card" class="gtr-card" data-tree-card>

View file

@ -24,6 +24,8 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { setDataBase, ensureD3 } from './src/utils/pplrData.js'
import { usePplrT } from './src/composables/usePplrT.js'
import { useHostedHelp } from '@map/composables/useHostedHelp.js'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const profileConfig = inject('profileConfig')
const cfg = computed(() => profileConfig?.value || {})
@ -32,6 +34,9 @@ setDataBase(resolveAssetsBase(cfg.value))
// Order and labels follow the original showcase page's tab bar.
// Tab labels are the chrome a reader sees first; they come from pplr.json (all 11 locales).
const t = usePplrT()
// The hub's "?" asks its HOST for this map's help (doxa:help); it shows only where a host answers.
const { hosted: helpHosted, askForHelp } = useHostedHelp()
const tb = useOptionalT('buttons')
const VIEWS = [
{ key: 'pplr-religion', label: 'Religion', tKey: 'pplr.tabs.religion', load: () => import('./src/views/pplr-religion.vue') },
{ key: 'pplr-rop-tree', label: 'ROP Tree', tKey: 'pplr.tabs.ropTree', load: () => import('./src/views/pplr-rop-tree.vue') },
@ -65,6 +70,9 @@ useShadowStyles(
'border-bottom:1px solid #d1d4cd;overflow-x:auto;z-index:50}' +
'.pdm-brand{font:700 12px/1 ui-monospace,monospace;letter-spacing:.06em;text-transform:uppercase;color:#3b463d;white-space:nowrap}' +
'.pdm-seg{display:inline-flex;gap:4px;flex-wrap:nowrap}' +
'.pdm-help{margin-inline-start:auto;width:28px;height:28px;border-radius:50%;border:1px solid #d1d4cd;background:#fff;' +
'color:#3b463d;font:700 15px/1 Georgia,serif;cursor:pointer;flex:0 0 auto}' +
'.pdm-help:focus-visible{outline:2px solid #3f8f5a;outline-offset:2px}' +
'.pdm-seg button{border:1px solid #d1d4cd;background:#fff;color:#3b463d;cursor:pointer;white-space:nowrap;' +
'font:600 12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:7px 11px;border-radius:999px}' +
'.pdm-seg button.on{background:#92b195;border-color:#92b195;color:#fff}' +
@ -81,6 +89,8 @@ useShadowStyles(
<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(v.tKey) }}</button>
</div>
<button v-if="helpHosted" type="button" class="pdm-help" :aria-label="tb('aboutThisMap')" :title="tb('aboutThisMap')"
@click="askForHelp($event.currentTarget)">?</button>
</div>
<div class="pdm-stage">
<component :is="COMPONENTS[view]" :key="view" />

View file

@ -54,6 +54,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { openPrintFrame } from '@map/composables/printInPlace.js'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import HelpButton from '@map/components/map-controls/HelpButton.vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { ensureRtlTextPlugin } from '@map/maps/rtl-text.js'
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
@ -641,7 +642,7 @@ useShadowStyles(
'.pwp-body{position:relative;flex:1;min-height:0;display:flex}' +
'.pwp-map{position:relative;flex:1;min-width:0}' +
'.pwp-map-el{position:absolute;inset:0}' +
'.pwp-map-controls{position:absolute;top:10px;right:10px;z-index:4}' +
'.pwp-map-controls{position:absolute;top:10px;right:10px;z-index:4;display:flex;flex-direction:column;gap:8px}' +
'.pwp[dir=rtl] .pwp-map-controls{right:auto;left:10px}' +
'.pwp-list{width:260px;flex-shrink:0;overflow-y:auto;background:#fff;border-left:1px solid #d1d4cd;padding:8px}' +
'.pwp-list-empty{padding:16px 10px;font-size:12px;color:#6d766e}' +
@ -732,7 +733,8 @@ useShadowStyles(
FullscreenButton requests fullscreen on this element's custom-element host, and falls
back to a CSS pseudo-fullscreen where the API does not exist (iOS Safari). A planner
embedded in a partner page or the gallery can take the whole screen for drawing. -->
<div class="pwp-map-controls"><FullscreenButton :map-container="mapContainer" /></div>
<!-- The "?" asks the host for this map's help (doxa:help); it shows only where a host answers. -->
<div class="pwp-map-controls"><FullscreenButton :map-container="mapContainer" /><HelpButton hosted /></div>
</div>
<div class="pwp-list">
<div v-if="polygons.length === 0" class="pwp-list-empty">

View file

@ -24,6 +24,8 @@
<button type="button" class="control-btn" :class="{ on: mode === 'bless' }" :aria-pressed="mode === 'bless' ? 'true' : 'false'" @click="setMode('bless')">{{ $t('wall.modeBless') }}</button>
<button type="button" class="control-btn" :class="{ on: mode === 'social' }" :aria-pressed="mode === 'social' ? 'true' : 'false'" @click="setMode('social')">{{ $t('wall.modeSocial') }}</button>
</div>
<button v-if="helpHosted" type="button" class="control-btn wall-help" :aria-label="$t('buttons.aboutThisMap')"
:title="$t('buttons.aboutThisMap')" @click="askForHelp($event.currentTarget)">?</button>
<span class="group-count">{{ $t('wall.groupsLoaded', { n: totalGroups }) }}</span>
</div>
</div>
@ -52,11 +54,17 @@
import { WallCanvas, groupLink } from '../wallCanvas.js'
import { buildBlessPosterHtml, screenPoster, PRESETS, DEFAULT_PRESET } from '../blessPoster.js'
import { openPrintFrame } from '@map/composables/printInPlace.js'
import { useHostedHelp } from '@map/composables/useHostedHelp.js'
// Wall of People Groups: a zoomable canvas wall (wallCanvas.js), Bless or Social cards, and the poster print.
export default {
name: 'PeopleGroupsWall',
// The page's profile-config, for its people-group LIST (library/lists/); handed to the store.
inject: { profileConfig: { default: null } },
// The wall's "?" asks its HOST for the wall's help (doxa:help); it shows only where a host answers.
setup() {
const { hosted, askForHelp } = useHostedHelp()
return { helpHosted: hosted, askForHelp }
},
data() {
return {
mode: 'bless', // 'bless' (default) | 'social'

View file

@ -91,6 +91,8 @@ body {
.mode-toggle .control-btn { border-radius: 0; }
.mode-toggle .control-btn:first-child { border-start-start-radius: 6px; border-end-start-radius: 6px; }
.mode-toggle .control-btn:last-child { border-start-end-radius: 6px; border-end-end-radius: 6px; }
/* The wall's "?": the same control as its neighbours, a serif glyph like the map controls' "?". */
.wall-help { font-family: Georgia, serif; font-weight: 700; min-width: 32px; }
.mode-toggle .control-btn.on { background: #262626; color: #fff; }
.control-btn:hover {
background: #0081d6;