/doxa-maps: the full maps SPA on the site + 'See all DOXA maps' pointer
- The drop-in now carries the bundler's app shell beside doxa-maps/ (index.html, nav, icons, pane-wrapper, external bundles, assets), so /js/doxa-maps-build/ index.html IS the composed single-page app — every map, lazy-loaded. - New page /doxa-maps frames it full-bleed (replaces the /maps easter egg). - The research page gains the pointer: 'See all DOXA maps →' (mirrors the prayer→research pattern). - server route serves the shell's ./api/maps/token.json from the site's own NUXT_PUBLIC_MAPBOX_TOKEN — no secret in the drop-in.
This commit is contained in:
parent
9d33a08113
commit
3c41b741de
11 changed files with 2528 additions and 40 deletions
42
app/pages/doxa-maps.vue
Normal file
42
app/pages/doxa-maps.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
// doxa.life/doxa-maps — the DOXA maps SPA (every map, one app).
|
||||
//
|
||||
// Frames the bundler's single-page app (the app-wrapper shell) from the
|
||||
// drop-in at public/js/doxa-maps-build/ — every map, table and dashboard the
|
||||
// bundler ships, composed into one lazy-loaded app, always exactly as current
|
||||
// as the build in this repo. Pointed to from the research page
|
||||
// ("See all DOXA maps →"). The shell resolves its Mapbox token from
|
||||
// ./api/maps/token.json, which this site serves from its own runtime config
|
||||
// (see server/routes/js/doxa-maps-build/api/maps/token.json.get.ts).
|
||||
useHead({
|
||||
title: 'All DOXA Maps',
|
||||
meta: [{ name: 'robots', content: 'noindex' }],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="maps-room">
|
||||
<iframe
|
||||
src="/js/doxa-maps-build/index.html"
|
||||
title="The DOXA maps app"
|
||||
class="maps-room-frame"
|
||||
loading="eager"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.maps-room {
|
||||
/* Full-bleed room: the staging index brings its own chrome. */
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #0a0e15;
|
||||
}
|
||||
.maps-room-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
// doxa.life/maps — the map room (an unlisted easter egg).
|
||||
//
|
||||
// Every map the doxa-maps bundler ships, in one place: this page frames the
|
||||
// bundler's own staging index from the drop-in at public/js/doxa-maps-build/,
|
||||
// so it is always exactly as current as the build in this repo — no separate
|
||||
// gallery to maintain. Linked from nowhere on purpose; those who find it,
|
||||
// find all the maps.
|
||||
useHead({
|
||||
title: 'Maps — DOXA',
|
||||
meta: [{ name: 'robots', content: 'noindex' }],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="maps-room">
|
||||
<iframe
|
||||
src="/js/doxa-maps-build/doxa-maps/index.html"
|
||||
title="All DOXA maps"
|
||||
class="maps-room-frame"
|
||||
loading="eager"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.maps-room {
|
||||
/* Full-bleed room: the staging index brings its own chrome. */
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #0a0e15;
|
||||
}
|
||||
.maps-room-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -39,6 +39,7 @@ useTextHighlight()
|
|||
<div class="stack stack--xs text-center">
|
||||
<h1 class="highlight" data-highlight-last>{{ t('Find a UUPG') }}</h1>
|
||||
<p><NuxtLink class="light-link" :to="localePath('/regions')">{{ t('By Region') }} →</NuxtLink></p>
|
||||
<p><NuxtLink class="light-link" to="/doxa-maps">{{ t('See all DOXA maps') }} →</NuxtLink></p>
|
||||
</div>
|
||||
|
||||
<!-- Research map embed — sits directly under the H1, above the UUPG
|
||||
|
|
|
|||
337
public/js/doxa-maps-build/external/research-dashboard-map.js
vendored
Normal file
337
public/js/doxa-maps-build/external/research-dashboard-map.js
vendored
Normal file
File diff suppressed because one or more lines are too long
548
public/js/doxa-maps-build/external/upg-neighbor.js
vendored
Normal file
548
public/js/doxa-maps-build/external/upg-neighbor.js
vendored
Normal file
File diff suppressed because one or more lines are too long
62
public/js/doxa-maps-build/icons.js
Normal file
62
public/js/doxa-maps-build/icons.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* icons.js — SELF-HOSTED icon set. No CDN, no icon font, no Google/YouTube request.
|
||||
*
|
||||
* These are hand-written 24x24 stroke paths in the Lucide idiom (ISC-licensed style:
|
||||
* 2px stroke, round cap/join, 24x24 box). Shipping them as inline SVG means:
|
||||
* - zero network requests (works offline, works in Capacitor from local assets)
|
||||
* - F-Droid safe (no proprietary blobs, no tracking, reproducible from source)
|
||||
* - no FOUT/icon-font flash
|
||||
*
|
||||
* To swap in a real icon package later, replace these path strings — the API
|
||||
* (`icon(name)` returning an <svg> string) stays the same.
|
||||
*/
|
||||
const P = {
|
||||
menu: '<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>',
|
||||
map: '<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/>',
|
||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/>',
|
||||
heart: '<path d="M19 14c1.5-1.5 3-3.5 3-5.5A4.5 4.5 0 0 0 12 6a4.5 4.5 0 0 0-10 2.5C2 10.5 3.5 12.5 5 14l7 7Z"/>',
|
||||
// `prayer` = Prayer Mobilization — two hands pressed together (praying): pointed
|
||||
// fingertips at top, knuckle bulges on the outer edges, wrists curling in at the base.
|
||||
prayer: '<path d="M12 4.2c-.9 1.4-1.9 2.6-2.9 3.8-1 1.2-1.6 2.6-1.6 4.2v3.3c0 1.3.7 2.4 1.8 3 .5.3 1.1-.1 1.1-.7"/><path d="M12 4.2c.9 1.4 1.9 2.6 2.9 3.8 1 1.2 1.6 2.6 1.6 4.2v3.3c0 1.3-.7 2.4-1.8 3-.5.3-1.1-.1-1.1-.7"/><path d="M12 4.2V17"/>',
|
||||
list: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9"/><path d="M16 3.1a4 4 0 0 1 0 7.8"/>',
|
||||
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V4s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
|
||||
target: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/>',
|
||||
pin: '<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
layers: '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
|
||||
flask: '<path d="M9 3h6v5l5 11a2 2 0 0 1-2 3H6a2 2 0 0 1-2-3l5-11z"/><line x1="9" y1="3" x2="15" y2="3"/><line x1="6" y1="15" x2="18" y2="15"/>',
|
||||
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/>',
|
||||
beaker: '<path d="M6 3h12"/><path d="M8 3v7l-3 8a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-3-8V3"/>',
|
||||
chevron: '<polyline points="9 18 15 12 9 6"/>',
|
||||
grid: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
tree: '<rect x="9" y="3" width="6" height="5" rx="1"/><rect x="2" y="16" width="6" height="5" rx="1"/><rect x="16" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M5 16v-2h14v2"/>',
|
||||
dots: '<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>',
|
||||
// `embed` = the <> script/code-embed mark. Signals "this map is the embeddable
|
||||
// one you paste into a partner site" (2026-07-29, for Prayer Progress).
|
||||
embed: '<polyline points="9 7 4 12 9 17"/><polyline points="15 7 20 12 15 17"/>',
|
||||
// `send` = Laborer Mobilization (sending labourers) — a paper-plane/send mark,
|
||||
// deliberately distinct from `heart` (Prayer Mobilization) and `users` (People Groups).
|
||||
send: '<path d="M21 3 L3 10.5 L9.5 13.5 L12.5 20 Z"/><line x1="21" y1="3" x2="9.5" y2="13.5"/>',
|
||||
// `poster` = a printable poster asset (Country Posters / Key City Posters) —
|
||||
// a framed sheet with a pinned marker, distinct from `map` and `pin`.
|
||||
poster: '<rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="12" cy="10" r="2.5"/><path d="M8 17h8"/>',
|
||||
// `search` = Material "search" magnifying glass, hand-drawn to Material's geometry
|
||||
// (circle r=7.5 centred at 10.5,10.5 with the handle running to ~20,20 at 45°).
|
||||
// Written out locally — NEVER fetched from a Google/Material CDN. That is a standing
|
||||
// law, not a style choice: a CDN icon request leaks the end user's IP to Google,
|
||||
// which in a persecution context is a safety risk. Self-hosted also = works offline
|
||||
// and inside Capacitor/F-Droid from local files.
|
||||
search: '<circle cx="10.5" cy="10.5" r="7.5"/><line x1="15.8" y1="15.8" x2="20.5" y2="20.5"/>',
|
||||
// `book` = Gospel Access (an open book / the Word reaching a place).
|
||||
book: '<path d="M12 6.5C12 6.5 9.5 4.5 4 4.5v13C9.5 17.5 12 19.5 12 19.5"/><path d="M12 6.5C12 6.5 14.5 4.5 20 4.5v13C14.5 17.5 12 19.5 12 19.5"/>',
|
||||
// `wifi` = Cities with Internet (connectivity arcs + node). Local, no CDN.
|
||||
wifi: '<path d="M3.5 9a13 13 0 0 1 17 0"/><path d="M6.8 12.4a8.5 8.5 0 0 1 10.4 0"/><path d="M10 15.7a4 4 0 0 1 4 0"/><circle cx="12" cy="19" r="1"/>',
|
||||
}
|
||||
|
||||
export function icon(name, cls = '') {
|
||||
const d = P[name] || P.map
|
||||
return `<svg class="ic ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`
|
||||
}
|
||||
1164
public/js/doxa-maps-build/index.html
Normal file
1164
public/js/doxa-maps-build/index.html
Normal file
File diff suppressed because it is too large
Load diff
62
public/js/doxa-maps-build/languages.js
Normal file
62
public/js/doxa-maps-build/languages.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* languages.js — the language list for the app wrapper's locale dropdown.
|
||||
*
|
||||
* PORTED from dx-life's single source of truth: `config/languages.ts`
|
||||
* (LANGUAGES / ENABLED_LANGUAGES / hideFromSwitcher). Kept DELIBERATELY MINIMAL:
|
||||
* dx-life's version also carries DeepL codes, Bible translation ids, glossary ids
|
||||
* and font flags — all of which belong to the CMS/translation pipeline, not to a
|
||||
* map wrapper. Only the four fields the switcher actually renders are kept, plus
|
||||
* `dir` (RTL) because the wrapper has to flip layout direction.
|
||||
*
|
||||
* WHY NOT A LIBRARY: the switcher is ~30 lines of state over this array. Adding a
|
||||
* dependency to render a <select> would be more complexity, not less — and every
|
||||
* candidate assumes a router (dx-life's uses `switchLocalePath()` because it
|
||||
* routes /es, /fr …). The wrapper is a STATIC app with no locale routing: it just
|
||||
* needs to emit a code. So: same DATA, simpler MECHANISM.
|
||||
*
|
||||
* THE CONTRACT (already built into the bundles — do not reinvent):
|
||||
* ProfileLoader reads `profile-config.locale` (falls back to `.lang`), lowercases
|
||||
* it, strips any region suffix ('pt-BR' -> 'pt'), drives the bundle's i18n global
|
||||
* locale from it, and provides `lang` to descendants. So switching language =
|
||||
* re-setting profile-config.locale on the mounted element. Nothing else.
|
||||
*/
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹' },
|
||||
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', dir: 'rtl' },
|
||||
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
|
||||
// Present in dx-life but hidden from its public switcher (hideFromSwitcher: true).
|
||||
// Kept here, flagged, so the list stays a faithful mirror of the source of truth.
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', hideFromSwitcher: true },
|
||||
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', hideFromSwitcher: true },
|
||||
// enabled:false in dx-life (routable/translatable but not offered): it, zh, ro.
|
||||
]
|
||||
|
||||
/** Languages offered in the dropdown. */
|
||||
export const SWITCHER_LANGUAGES = LANGUAGES.filter(l => !l.hideFromSwitcher)
|
||||
|
||||
export function getLanguage(code) {
|
||||
return LANGUAGES.find(l => l.code === code)
|
||||
}
|
||||
|
||||
/** Normalise any incoming tag to a supported code ('pt-BR' -> 'pt'; unknown -> 'en'). */
|
||||
export function normaliseLocale(tag) {
|
||||
const base = String(tag || '').split('-')[0].toLowerCase()
|
||||
return LANGUAGES.some(l => l.code === base) ? base : 'en'
|
||||
}
|
||||
|
||||
/** Persisted choice (survives reload), defaulting to the browser language. */
|
||||
const KEY = 'doxa-maps:locale'
|
||||
export function loadLocale() {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY)
|
||||
if (saved) return normaliseLocale(saved)
|
||||
} catch (_) { /* storage may be unavailable */ }
|
||||
return normaliseLocale(navigator?.language)
|
||||
}
|
||||
export function saveLocale(code) {
|
||||
try { localStorage.setItem(KEY, code) } catch (_) { /* non-fatal */ }
|
||||
}
|
||||
128
public/js/doxa-maps-build/nav.js
Normal file
128
public/js/doxa-maps-build/nav.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* nav.js — the sidebar data model for the app-wrapper-material layout.
|
||||
*
|
||||
* Extends a conventional `SidebarNavItem` shape ({ to, label, icon, children })
|
||||
* with the two things doxa-maps needs:
|
||||
* - `kind` : 'item' | 'section' | 'folder'
|
||||
* section = always-open, labelled group (YouTube "Subscriptions" style)
|
||||
* folder = collapsible group (click to open/close)
|
||||
* - `target` : 'both' | 'desktop' | 'mobile' <-- per-target packaging flag.
|
||||
* 'desktop' items are hidden on mobile AND excluded from the
|
||||
* Capacitor build's webDir (the mobile build copies fewer bundles).
|
||||
*
|
||||
* Order matters: this array renders top-to-bottom, exactly as authored.
|
||||
* Icons are LOCAL names resolved from ./icons/<name>.svg — no CDN, no icon font.
|
||||
* (F-Droid requires self-hosted, non-proprietary assets.)
|
||||
*/
|
||||
export const NAV = [
|
||||
// ── PRAYER MOBILIZATION FIRST: the prayer map is the landing view, so this
|
||||
// section sits at the TOP and its first child is the DEFAULT profile.
|
||||
// (2026-07-29: "so that the first map they see is the prayer map".)
|
||||
{
|
||||
kind: 'section', id: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart', target: 'both',
|
||||
children: [
|
||||
// Prayer Progress = the research map opened on its PRAYER tab (colorStrategy
|
||||
// 'prayerProgress'). That tab is tabs[0] in the bundle, so it is already the
|
||||
// default view — no separate bundle needed. The `embed` icon (<>) marks it as
|
||||
// the embeddable/share-first map.
|
||||
{ kind: 'item', id: 'prayer-progress', label: 'Prayer Progress', icon: 'embed', target: 'both' },
|
||||
{ kind: 'item', id: 'upg-100-list', label: 'My UPG 100 List', icon: 'list', target: 'both' },
|
||||
// REMOVED 'Prayer List' (2026-07-29): not a real app.
|
||||
],
|
||||
},
|
||||
|
||||
// ── LABORER MOBILIZATION — mobilising the labourers/disciple-makers themselves
|
||||
// (as distinct from mobilising prayer). UPG Neighbor MOVED here from Prayer
|
||||
// Mobilization: it is a labourer/sending tool, not a prayer tool.
|
||||
// (2026-07-29).
|
||||
{
|
||||
kind: 'section', id: 'laborer-mobilization', label: 'Laborer Mobilization', icon: 'send', target: 'both',
|
||||
children: [
|
||||
{ kind: 'item', id: 'upg-neighbor', label: 'UPG Neighbor', icon: 'users', target: 'both' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── RESEARCH — a TOP-LEVEL section, PEER of Prayer/Laborer Mobilization
|
||||
// (2026-07-29: "a section to the same degree as Laborer", i.e. a
|
||||
// sibling, NOT nested under it). Holds the research/general maps.
|
||||
{
|
||||
kind: 'section', id: 'research', label: 'Research', icon: 'search', target: 'both',
|
||||
children: [
|
||||
{ kind: 'item', id: 'research-map', label: 'Research Map', icon: 'map', target: 'both' },
|
||||
// 'All People Groups' = the FULL global people-group set (~15,000), as distinct
|
||||
// from the ~2,100-UPG DX cluster the prayer map plots. Different datasets —
|
||||
// keep them straight. (The "15k" suffix was dropped from the label at the
|
||||
// maintainer's request 2026-07-29; the distinction is recorded here instead.)
|
||||
// ⚠ NO BUNDLE YET — renders the placeholder card.
|
||||
{ kind: 'item', id: 'all-people-groups', label: 'All People Groups', badge: 'soon', icon: 'globe', target: 'both' },
|
||||
// REMOVED 'Reached People Groups' (2026-07-29) — added and withdrawn the
|
||||
// same round; do not re-add without an explicit ask.
|
||||
// REMOVED 'Simple Map' (2026-07-29). REASON WORTH KEEPING: the
|
||||
// doxa-simple-map BUNDLE is not one map — it carries THREE (prayer, adoption,
|
||||
// engagement). doxa-maps only wants to preview PRAYER, and that is already
|
||||
// surfaced as 'Prayer Progress' in Prayer Mobilization. Exposing "Simple Map"
|
||||
// would advertise adoption+engagement views we are not previewing here.
|
||||
// => If a simple prayer-only view is ever wanted as its own entry, add a
|
||||
// profile-config that pins doxa-simple-map to its prayer profile only.
|
||||
],
|
||||
},
|
||||
|
||||
// ── GOSPEL ACCESS — where the gospel can actually REACH: the infrastructure
|
||||
// (internet-connected cities) and the assets (gospel resources) that make
|
||||
// access possible. Populated by the maintainer 2026-07-29.
|
||||
// Both items are wired to real bundles (cities-internet + gospel-resources).
|
||||
{
|
||||
kind: 'section', id: 'gospel-access', label: 'Gospel Access', icon: 'book', target: 'both',
|
||||
children: [
|
||||
{ kind: 'item', id: 'cities-with-internet', label: 'Cities with Internet', icon: 'wifi', target: 'both' },
|
||||
{ kind: 'item', id: 'gospel-resources', label: 'Gospel Resources', icon: 'book', target: 'both' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── 'People Groups' section REMOVED (2026-07-29): it is now empty —
|
||||
// Countries -> Prayer Mobilization as Country Posters; Impact Map -> Experimental
|
||||
// maps; People Pins -> deleted outright.
|
||||
|
||||
// ── DASHBOARDS — a SECTION (always visible, no folder to open), DESKTOP ONLY
|
||||
// (excluded from the mobile/Capacitor build). "Big Picture" carries a Desktop Only badge. ──
|
||||
{
|
||||
kind: 'section', id: 'dashboards', label: 'Dashboards', icon: 'monitor', target: 'desktop',
|
||||
children: [
|
||||
{ kind: 'item', id: 'research-dashboard', label: 'Big Picture', icon: 'chart', target: 'desktop' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── a FOLDER at the BOTTOM (design note: "experimental maps") ──
|
||||
{
|
||||
kind: 'folder', id: 'experimental', label: 'Experimental maps', icon: 'flask', target: 'both',
|
||||
open: false,
|
||||
children: [
|
||||
{ kind: 'item', id: 'nationmapper', label: 'Nationmapper', badge: 'soon', icon: 'share', target: 'both' },
|
||||
// Country Posters MOVED here from Prayer Mobilization to Experimental.
|
||||
{ kind: 'item', id: 'countries-map', label: 'Country Posters', icon: 'poster', target: 'both' },
|
||||
// Outreach Planner = geo-steward, renamed + moved out of the Dashboard folder.
|
||||
{ kind: 'item', id: 'geo-steward', label: 'Outreach Planner', badge: 'soon', icon: 'layers', target: 'both' },
|
||||
// Key City Posters MOVED here from Laborer Mobilization.
|
||||
{ kind: 'item', id: 'key-city-posters', label: 'Key City Posters', icon: 'poster', target: 'both' },
|
||||
// Full UPG-Neighbor hub (all lenses: cockpit / flower / lang-cluster / send).
|
||||
// The production 'UPG Neighbor' in Laborer Mobilization is the focused lang-cluster map.
|
||||
{ kind: 'item', id: 'upg-neighbor-lab', label: 'UPG Neighbor Lab', icon: 'flask', target: 'both' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Filter the tree for a build/runtime target. Used by BOTH the UI and the packager.
|
||||
* RECURSIVE — the tree is no longer only two levels deep (a section may hold a
|
||||
* 'subgroup', e.g. Laborer Mobilization > Research > Research Map), so a flat
|
||||
* one-level filter would silently drop or mis-filter nested entries.
|
||||
*/
|
||||
export function navForTarget(nav, target /* 'desktop' | 'mobile' */) {
|
||||
const keep = (n) => n.target === 'both' || n.target === target
|
||||
const walk = (nodes) => nodes
|
||||
.filter(keep)
|
||||
.map((n) => (n.children ? { ...n, children: walk(n.children) } : n))
|
||||
// prune groups whose children were all filtered out (an empty group is noise)
|
||||
.filter((n) => !n.children || n.children.length > 0)
|
||||
return walk(nav)
|
||||
}
|
||||
176
public/js/doxa-maps-build/pane-wrapper/pane-wrapper.js
Normal file
176
public/js/doxa-maps-build/pane-wrapper/pane-wrapper.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* pane-wrapper — the MFE pane GRID, a layout nested inside app-wrapper-material.
|
||||
*
|
||||
* WHAT IT IS
|
||||
* `app-wrapper-material` owns the chrome (header, side menu, the View fieldset).
|
||||
* This layout owns the STAGE INSIDE it: a parameterized grid of panes, each pane
|
||||
* holding a different app-profile MFE that is another VIEW of the same subject.
|
||||
* `my-upg-100-list` is a family — a map view and a poster-wall view — not one app
|
||||
* with tabs. Together the two layouts form the app harness that displays the
|
||||
* custom elements shipped in the vite bundles.
|
||||
*
|
||||
* THE MOTION CONTRACT (the design decision everything else follows from)
|
||||
* • VERTICAL neighbours are ONE SCROLLABLE PAGE. Both panes are live; you scroll
|
||||
* between them. The edge strips (useEdgeScrollAffordance) are what let you
|
||||
* escape a scroll-capturing child like a Mapbox canvas.
|
||||
* • HORIZONTAL movement is a FLY-TO. The target column is NOT loaded until you
|
||||
* go there, and the origin column is released on arrival.
|
||||
* So AT MOST TWO PANES ARE EVER ALIVE. That is not an arbitrary cap — it is what
|
||||
* preserves the ~16-WebGL-context budget the wrapper's teardown has always
|
||||
* protected. A naive "keep all four panes mounted" grid would break on a phone.
|
||||
*
|
||||
* WHY A COLUMN IS ONE SCROLLING PAGE RATHER THAN TWO SWAPPED VIEWS
|
||||
* A pane you scroll to keeps its sibling on screen during the transition, so the
|
||||
* relationship between the views stays legible ("the list is UNDER the map"). A
|
||||
* swap teaches nothing.
|
||||
*
|
||||
* 2×2 IS A DEFAULT, NOT A CEILING. Panes carry [row, col] coordinates rather than
|
||||
* a fixed enum, so the grid expands (2×3, 3×3 …) and contracts (a single column,
|
||||
* or one pane) from the same code. A profile with no `panes` key behaves exactly
|
||||
* as before this layout existed.
|
||||
*
|
||||
* AR is the reason the grid exists at all: today you see one pane (or one
|
||||
* scrolling column), but the coordinates mean a future AR client can zoom out and
|
||||
* see every pane laid out like posters on a wall.
|
||||
*/
|
||||
|
||||
export const PANE_GRID_STYLES = `
|
||||
/* The column that scrolls. Vertical neighbours stack here and the user scrolls
|
||||
between them; scroll-snap makes each pane settle cleanly instead of stopping
|
||||
half-way between two views. */
|
||||
.pane-col{
|
||||
position:absolute; inset:0; overflow-y:auto; overflow-x:hidden;
|
||||
scroll-snap-type:y mandatory; scroll-behavior:smooth;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
/* DOUBLE SCROLLBAR FIX. A pane's own content (e.g. the poster wall) scrolls
|
||||
inside the pane; this column only scrolls BETWEEN panes. With both visible
|
||||
the desktop showed two bars side by side. The column's bar is hidden — pane
|
||||
navigation is driven by the View switcher and scroll-snap, not by dragging a
|
||||
bar — while the inner content keeps its own, which is the one a user wants.
|
||||
Scrolling still works here; only the indicator is suppressed. */
|
||||
scrollbar-width:none; /* Firefox */
|
||||
-ms-overflow-style:none; /* legacy Edge */
|
||||
}
|
||||
.pane-col::-webkit-scrollbar{ width:0; height:0; } /* WebKit/Blink */
|
||||
/* Each pane fills the stage exactly, so "scroll down" moves you one full view. */
|
||||
.pane{
|
||||
position:relative; width:100%; height:100%;
|
||||
scroll-snap-align:start; scroll-snap-stop:always;
|
||||
}
|
||||
/* The live custom element fills its pane. */
|
||||
.pane > .pane-mount{ position:absolute; inset:0; }
|
||||
/* Placeholder copy for a pane whose profile is not built yet — same card styling
|
||||
as the wrapper's own placeholder so an unbuilt pane never looks broken. */
|
||||
.pane-placeholder{
|
||||
position:absolute; inset:0; display:grid; place-items:center; padding:24px;
|
||||
text-align:center;
|
||||
}
|
||||
.pane-placeholder .pane-card{
|
||||
max-width:520px; background:var(--card,#fff); border-radius:14px; padding:22px 26px;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.12),0 6px 20px rgba(0,0,0,.08);
|
||||
}
|
||||
.pane-placeholder h2{ margin:0 0 8px; font-size:18px; font-weight:600 }
|
||||
.pane-placeholder p{ margin:0; font-size:13px; line-height:1.55; opacity:.78 }
|
||||
/* A small persistent hint that there IS something below — the affordance the
|
||||
edge strips reinforce once you start interacting. Hidden on the last pane. */
|
||||
.pane-more{
|
||||
position:absolute; left:50%; bottom:10px; transform:translateX(-50%);
|
||||
display:flex; align-items:center; gap:6px; padding:5px 12px; border-radius:999px;
|
||||
background:rgba(0,0,0,.55); color:#fff; font-size:11px; letter-spacing:.02em;
|
||||
pointer-events:none; z-index:6; opacity:.9;
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Build the pane column for a profile.
|
||||
*
|
||||
* @param {Object} spec the PROFILES registry entry
|
||||
* @param {Object} opts
|
||||
* @param {HTMLElement} opts.host container to render into (the wrapper's #slot)
|
||||
* @param {(pane:Object, mountEl:HTMLElement) => Promise<void>} opts.mountPane
|
||||
* called for each pane that should be LIVE; the caller owns element creation
|
||||
* so this layout never needs to know about bundles, tags or tokens.
|
||||
* @returns {{ panes:Object[], scrollTo:(paneId:string)=>void, destroy:()=>void }}
|
||||
*/
|
||||
export function buildPaneColumn(spec, { host, mountPane }) {
|
||||
const panes = Array.isArray(spec?.panes) ? spec.panes : []
|
||||
// No pane declaration → not a pane profile. The caller falls back to its
|
||||
// original single-mount path, so nothing changes for existing profiles.
|
||||
if (panes.length < 2) return null
|
||||
|
||||
// v1 renders ONE COLUMN (col 0). Other columns are reachable by fly-to, which is
|
||||
// a later slice — but the coordinates are already carried on every pane, so that
|
||||
// work does not require reshaping this data.
|
||||
const column = panes
|
||||
.filter(p => (p.at?.[1] ?? 0) === 0)
|
||||
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
|
||||
|
||||
if (column.length < 2) return null
|
||||
|
||||
const col = document.createElement('div')
|
||||
col.className = 'pane-col'
|
||||
|
||||
const mounts = new Map()
|
||||
column.forEach((pane, i) => {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'pane'
|
||||
el.dataset.paneId = pane.id
|
||||
const mount = document.createElement('div')
|
||||
mount.className = 'pane-mount'
|
||||
el.appendChild(mount)
|
||||
mounts.set(pane.id, mount)
|
||||
|
||||
// A pane with no bundle yet still renders — as a labelled placeholder, so the
|
||||
// column is scrollable and testable before every view profile exists.
|
||||
if (pane.placeholder) {
|
||||
const ph = document.createElement('div')
|
||||
ph.className = 'pane-placeholder'
|
||||
ph.innerHTML = `<div class="pane-card"><h2>${pane.label}</h2><p>${pane.placeholder}</p></div>`
|
||||
el.appendChild(ph)
|
||||
}
|
||||
|
||||
// "scroll for more" hint on every pane except the last.
|
||||
// OPT-OUT per profile via `paneHint: false` in the PROFILES registry entry.
|
||||
// The header already carries a Map | Posters view switcher, so on a profile
|
||||
// where that is visible this pill is a second, redundant control that just
|
||||
// sits on top of the map. Left ON by default so existing profiles are
|
||||
// unchanged. (design note 2026-08-01: "component that says posters below should
|
||||
// not exist.")
|
||||
if (i < column.length - 1 && spec?.paneHint !== false) {
|
||||
const more = document.createElement('div')
|
||||
more.className = 'pane-more'
|
||||
more.innerHTML = `<span>${column[i + 1].label} below</span><span aria-hidden="true">↓</span>`
|
||||
el.appendChild(more)
|
||||
}
|
||||
|
||||
col.appendChild(el)
|
||||
})
|
||||
|
||||
host.appendChild(col)
|
||||
|
||||
return {
|
||||
panes: column,
|
||||
/** Animate to a pane by id — this is what the View fieldset tabs call. */
|
||||
scrollTo(paneId) {
|
||||
const target = col.querySelector(`.pane[data-pane-id="${paneId}"]`)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
},
|
||||
/** Which pane is currently filling the viewport (for URL sync). */
|
||||
current() {
|
||||
const tops = column.map(p => {
|
||||
const el = col.querySelector(`.pane[data-pane-id="${p.id}"]`)
|
||||
return { id: p.id, d: Math.abs((el?.offsetTop ?? 0) - col.scrollTop) }
|
||||
})
|
||||
tops.sort((a, b) => a.d - b.d)
|
||||
return tops[0]?.id || column[0].id
|
||||
},
|
||||
onScroll(cb) {
|
||||
let t = null
|
||||
const h = () => { if (t) clearTimeout(t); t = setTimeout(cb, 120) }
|
||||
col.addEventListener('scroll', h, { passive: true })
|
||||
return () => { if (t) clearTimeout(t); col.removeEventListener('scroll', h) }
|
||||
},
|
||||
mountEl: (paneId) => mounts.get(paneId) || null,
|
||||
destroy() { col.remove(); mounts.clear() },
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// The doxa-maps SPA shell (served from public/js/doxa-maps-build/) resolves its
|
||||
// Mapbox token from ./api/maps/token.json relative to itself — which is THIS
|
||||
// route. Serving it from runtime config means the drop-in ships no secret and
|
||||
// the site's own NUXT_PUBLIC_MAPBOX_TOKEN powers the whole SPA.
|
||||
export default defineEventHandler((event) => {
|
||||
const token = (useRuntimeConfig(event).public as { mapboxToken?: string }).mapboxToken || ''
|
||||
return { token, type: token.slice(0, 2) }
|
||||
})
|
||||
Loading…
Reference in a new issue