doxa-maps/layouts/app-wrapper-material/index.html

1174 lines
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Doxa Maps</title>
<link rel="icon" href="data:," />
<!-- PERF: warm the connections + preload the default profile bundle so mapbox, the
bundle, and the API all fetch in parallel instead of serially (was ~10s cold). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link rel="preconnect" href="https://pray.doxa.life" crossorigin />
<link rel="modulepreload" href="./doxa-maps/doxa-simple-map/doxa-simple-map.js" />
<script>
// DEEP-LINK PRELOAD — the static modulepreload above only warms the DEFAULT
// landing bundle; a #/research-map deep link used to fetch its bundle strictly
// AFTER the module ran. This tiny script reads the hash at parse time and adds a
// modulepreload for the resolved profile's bundle so the fetch parallelizes with
// the Mapbox peer below. KEEP THIS MAP IN SYNC with the PROFILES registry at the
// bottom of this file (each entry's `src`) — same ids, same srcs.
(function(){
var M = {
'prayer-progress':'./doxa-maps/doxa-simple-map/doxa-simple-map.js',
'upg-100-list':'./doxa-maps/my-upg-100-list/my-upg-100-list.js',
'countries-map':'./doxa-maps/country-posters/country-posters.js',
'key-city-posters':'./doxa-maps/key-cities/key-cities.js',
'all-people-groups':'./doxa-maps/all-people-groups/all-people-groups.js',
'cities-with-internet':'./doxa-maps/cities-internet/cities-internet.js',
'gospel-resources':'./doxa-maps/gospel-resources/gospel-resources.js',
'research-map':'./doxa-maps/doxa-research-map/doxa-research-map.js',
'research-dashboard':'./external/research-dashboard-map.js',
'upg-neighbor':'./external/upg-neighbor.js',
'upg-neighbor-lab':'./external/upg-neighbor.js'
}
try {
var id = decodeURIComponent((location.hash || '').replace(/^#\/?/, '')).trim()
var src = M[id]
if (src && !src.endsWith('doxa-simple-map.js')) {
var l = document.createElement('link')
l.rel = 'modulepreload'; l.href = src
document.head.appendChild(l)
}
} catch (e) { /* never block boot over a preload */ }
})()
</script>
<!-- ── Mapbox GL peer: loaded ONCE by the wrapper (the shell owns the version).
Profile bundles never import mapbox-gl — they use the `window.mapboxgl`
global this shell provides, so they all share THIS single instance — one
download, one parse, one library.
TODO(prod): self-host these files same-origin so CSP can be `worker-src 'self'`
instead of `blob:`, and so the F-Droid/offline builds carry no CDN dep. ── -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script defer src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script defer src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- The people-group API the map bundles fetch from. The generated instance
pages set this too (simple-map.instances.json `apiBaseUrl`); the wrapper must
set it as well or those bundles fall back to same-origin and 404. -->
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- No token script here. A token comes from the endpoint the build writes
(./api/maps/token.json, from MAPBOX_TOKEN) or from window.DOXA_TOKEN if a host
page sets it. A <script src> for a file that is deliberately never deployed
404s on every production page load, and it was the file that kept leaking into
the served root. One mechanism, local and deployed alike. -->
<style>
/* ══════════════════════════════════════════════════════════════════
doxa-maps · layouts/app-wrapper-material
Material-ish surface values drawn from Quasar's palette, structured
like the Quasar YouTube layout gallery. Self-hosted everything.
Theming via CSS custom properties (the --ui-* custom-property idiom).
══════════════════════════════════════════════════════════════════ */
:root{
--ui-bg:#ffffff; --ui-bg-elevated:#f9f9f9; --ui-bg-accented:#f0f0f0;
--ui-border:#e5e5e5; --ui-text:#0f0f0f; --ui-text-muted:#606060;
--ui-primary:#1a73e8; /* matches the research map's active-tab accent */
--rail:72px; --drawer:240px; --header:56px;
/* ── SAFE-AREA TOP INSET — read the research before changing this ──────────
Most mobile apps reserve the strip under the status bar / camera cutout, and
the platform way to do that is the safe-area inset. BUT: on ANDROID WEBVIEW
< 140 `env(safe-area-inset-top)` returns 0 — a long-standing Chromium bug
(ionic-team/capacitor#7648, #2840), NOT a Capacitor bug. So a CSS-only
env() fix SILENTLY DOES NOTHING on many real devices, which is exactly the
overlap being reported on a Pixel.
The documented 2026 approach is therefore a 3-step cascade:
1. var(--safe-area-inset-top) ← injected from NATIVE by the System Bars /
safe-area plugin; correct even on the broken WebViews
2. env(safe-area-inset-top) ← the standard, correct on iOS + WebView 140+
3. a hard floor ← so the header is never flush to the very
top even when both report 0 (older Android, or a browser tab)
`viewport-fit=cover` in the <meta> is REQUIRED for env() to be non-zero at all. */
--sat: max(var(--safe-area-inset-top, 0px), env(safe-area-inset-top, 0px), var(--sat-floor));
--sat-floor: 0px; /* desktop/browser: no reserved strip needed */
--shadow-2:0 1px 5px rgba(0,0,0,.2),0 2px 2px rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.12);
}
@media (prefers-color-scheme: dark){
:root{ --ui-bg:#0f0f0f; --ui-bg-elevated:#181818; --ui-bg-accented:#272727;
--ui-border:#303030; --ui-text:#f1f1f1; --ui-text-muted:#aaa; --ui-primary:#64b5f6; }
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:Roboto,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
background:var(--ui-bg); color:var(--ui-text); overflow:hidden;
-webkit-font-smoothing:antialiased;
}
.ic{width:24px;height:24px;flex:none}
/* ── HEADER ─────────────────────────────────────────────── */
/* ── HEADER — safe-area aware ──────────────────────────────────────────────
BUG FIX: on a Pixel (and any notch/punch-hole device) the header sat UNDER the
status bar, so the battery/clock/camera overlapped our controls. There was no
safe-area handling at all. We now PAD the header by the top inset and grow the
fixed elements below it by the same amount, so nothing hides under the system UI.
`viewport-fit=cover` is already set in the <meta>, which is what makes
env(safe-area-inset-*) report non-zero inside Capacitor. */
.hdr{
position:fixed; inset:0 0 auto 0; z-index:30;
height:calc(var(--header) + var(--sat));
padding-top:var(--sat);
display:flex; align-items:center; gap:6px;
padding-left:max(8px, var(--safe-area-inset-left, env(safe-area-inset-left, 0px)));
padding-right:max(8px, var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
}
.iconbtn{
width:40px;height:40px;border:0;background:none;color:var(--ui-text);
border-radius:50%; display:grid;place-items:center;cursor:pointer;
transition:background .15s;
}
.iconbtn:hover{background:var(--ui-bg-accented)}
.iconbtn:active{background:var(--ui-border)}
/* BUG FIX (mobile): the text brand rendered oversized and OVERLAPPED the
hamburger, making the menu untappable — the worst bug in the set. It is now
the real logo image, height-capped, and `pointer-events:none` so it can never
intercept a tap meant for the hamburger even if it overflows. */
.brand{display:flex;align-items:center;min-width:0;pointer-events:none}
.brand img{height:26px;width:auto;max-width:150px;object-fit:contain;display:block}
@media (prefers-color-scheme: dark){ .brand img{filter:invert(1) hue-rotate(180deg)} }
.spacer{flex:1}
/* ── Select View: a real PrimeVue-style FIELDSET, CENTERED in the header ──
Semantic <fieldset> + <legend>; the legend sits on the top border line
(PrimeVue Fieldset structure). Absolutely centered so it stays mid-header
regardless of the brand/menu widths on either side. */
.viewsel{
/* BUG FIX (mobile): this was `top:50%` + translate(-50%,-50%), which centres on
the header's FULL height — INCLUDING the safe-area padding-top. Every other
control sits inside the padding box, so the fieldset floated ~half the inset
ABOVE them and the header looked unaligned.
Centring inside the PADDING BOX instead keeps it on the same optical row:
offset from the padded top by half the content height, and cancel only the
horizontal half in the transform. */
/* Centre it WITHOUT knowing its own height. A hardcoded --viewsel-h was wrong
because the fieldset is 37px on mobile (legend hidden) but 44px on desktop
(legend visible) — one constant cannot serve both.
Instead: anchor to the vertical MIDDLE of the padding box, then pull back by
half of the element's OWN height via translateY(-50%). Self-measuring, so it
stays aligned at any breakpoint and whatever the safe-area inset turns out
to be. `top` uses the padded content box, which is what the sibling controls
sit in — that was the original bug. */
position:absolute; left:50%;
top:calc(var(--sat) + var(--header) / 2);
transform:translate(-50%, -50%);
margin:0; padding:0 10px 2px; border:1px solid var(--ui-border);
border-radius:8px; background:var(--ui-bg);
}
.viewsel > legend{
/* CENTERED inside the fieldset (default legend alignment is inline-start) */
margin:0 auto; padding:0 6px;
font-size:10px; text-transform:uppercase; letter-spacing:.7px;
color:var(--ui-text-muted); font-weight:700;
}
/* One legend, two labels: the full wording where there is room, "View" on a phone. */
.viewsel > legend .lg-short{display:none}
@media (max-width:1023px){
.viewsel > legend .lg-full{display:none}
.viewsel > legend .lg-short{display:inline}
}
/* Slim UNDERLINE indicator (mirrors the research map's own active-tab style:
a 2px accent bar under the label, #1a73e8) — not a filled button. */
.seg{display:flex;gap:2px}
.seg button{
position:relative;
display:flex;align-items:center;gap:6px;
border:0;background:none;color:var(--ui-text-muted);
padding:6px 12px 7px;font-size:13px;font-weight:500;cursor:pointer;
transition:color .15s; font-family:inherit;
}
.seg button .ic{width:17px;height:17px}
.seg button::after{
content:''; position:absolute; left:8px; right:8px; bottom:0; height:2px;
border-radius:2px 2px 0 0; background:transparent; transition:background .15s;
}
.seg button:hover{color:var(--ui-text)}
.seg button:hover::after{background:var(--ui-border)}
.seg button[aria-pressed="true"]{color:var(--ui-primary);font-weight:600}
.seg button[aria-pressed="true"]::after{background:var(--ui-primary)}
/* ── LOCALE SWITCHER (flag + native-name pattern) ── */
.langsel{position:relative}
.langtrigger{
display:flex;align-items:center;gap:7px;
border:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text);
border-radius:8px;padding:6px 10px;font-size:13px;font-weight:500;cursor:pointer;
font-family:inherit; transition:background .15s;
}
/* BUG FIX (mobile): the full native name ("Español", "Português") ate the header.
Below 1024px show the FLAG only; the 2-letter code is the text fallback for
locales whose flag may not render (and for screen-reader clarity). */
.langtrigger .langname{display:none}
.langtrigger .langcode{display:none;font-size:11px;font-weight:700;letter-spacing:.4px}
@media (min-width:1024px){ .langtrigger .langname{display:inline} }
/* MOBILE FLOOR: reserve the status-bar strip even when BOTH inset sources report
0 (Android WebView <140 bug, or a plain mobile browser tab). 24px is the common
Android status-bar height; on devices that DO report an inset the max() above
wins, so a taller punch-hole cutout is still respected. Desktop keeps 0. */
@media (max-width:1023px){ :root{ --sat-floor:24px } }
@media (max-width:1023px){
.langtrigger{padding:6px 8px;gap:5px}
.langtrigger .caret{display:none}
}
.langtrigger:hover{background:var(--ui-bg-accented)}
.flag{font-size:15px;line-height:1}
.caret{
width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;
border-top:5px solid var(--ui-text-muted); margin-left:1px;
}
.langmenu{
position:absolute;top:calc(100% + 6px);right:0;min-width:190px;z-index:40;
list-style:none;margin:0;padding:6px;
background:var(--ui-bg);border:1px solid var(--ui-border);border-radius:10px;
box-shadow:var(--shadow-2);max-height:60vh;overflow-y:auto;
}
.langmenu li{margin:0}
.langmenu button{
display:flex;align-items:center;gap:9px;width:100%;
border:0;background:none;color:var(--ui-text);font-family:inherit;
padding:8px 10px;border-radius:7px;font-size:13px;cursor:pointer;text-align:left;
}
.langmenu button:hover{background:var(--ui-bg-accented)}
.langmenu button[aria-selected="true"]{color:var(--ui-primary);font-weight:600}
/* RTL locales flip the flag to the trailing side, as is conventional */
[dir="rtl"] .flag{order:2}
/* ── OVERFLOW ("three dots") MENU ─────────────────────────────────────────
On MOBILE the standalone language pill is hidden and the picker lives in here,
so the header only carries: hamburger · logo · view tabs · ⋮ */
.appmenuwrap{position:relative}
.appmenu{
position:absolute;top:calc(100% + 6px);right:0;min-width:210px;z-index:40;
background:var(--ui-bg);border:1px solid var(--ui-border);border-radius:10px;
box-shadow:var(--shadow-2);padding:6px;max-height:70vh;overflow-y:auto;
}
.appmenu[hidden]{display:none}
.appmenu-sect{
font-size:10px;text-transform:uppercase;letter-spacing:.7px;font-weight:700;
color:var(--ui-text-muted);padding:8px 10px 4px;
}
.langmenu-inline{list-style:none;margin:0;padding:0}
.langmenu-inline button{
display:flex;align-items:center;gap:9px;width:100%;
border:0;background:none;color:var(--ui-text);font-family:inherit;
padding:9px 10px;border-radius:7px;font-size:14px;cursor:pointer;text-align:left;
}
.langmenu-inline button:hover{background:var(--ui-bg-accented)}
.langmenu-inline button[aria-selected="true"]{color:var(--ui-primary);font-weight:600}
/* MOBILE: drop the header language pill entirely — it lives in ⋮ now. */
@media (max-width:1023px){ .langsel{display:none} }
/* DESKTOP: there is room, so keep the pill and hide the menu's language section. */
@media (min-width:1024px){ .appmenu .appmenu-sect, .appmenu .langmenu-inline{display:none} }
/* ── SIDEBAR ────────────────────────────────────────────── */
.side{
position:fixed; top:calc(var(--header) + var(--sat)); bottom:0; left:0; width:var(--drawer);
background:var(--ui-bg); border-right:1px solid var(--ui-border);
overflow-y:auto; overflow-x:hidden; z-index:20;
padding:8px 0 24px;
scrollbar-width:thin;
}
.side::-webkit-scrollbar{width:8px}
.side::-webkit-scrollbar-thumb{background:var(--ui-border);border-radius:4px}
/* ── DRAWER BRAND ─────────────────────────────────────────────────────────
The logo does not fit a 390px header beside the hamburger, view tabs and ⋮.
So on MOBILE the header shows no logo and the OPEN DRAWER shows it large —
the drawer is full-width and has the room. Desktop keeps the header logo and
hides this one, so the mark never appears twice. */
.side-brand{display:none}
.side-brand img{height:auto;width:100%;max-width:190px;display:block;margin:6px auto 14px;
padding:0 12px;box-sizing:border-box}
@media (prefers-color-scheme: dark){ .side-brand img{filter:invert(1) hue-rotate(180deg)} }
@media (max-width:1023px){
.side-brand{display:block;border-bottom:1px solid var(--ui-border);margin-bottom:6px}
.brand{display:none} /* no logo in the header on mobile */
}
/* In the desktop RAIL the drawer is only 72px wide — never show the wordmark there. */
body.rail .side .side-brand{display:none}
/* DESKTOP collapsed → RAIL: icons stay visible, labels hide (YouTube mini-guide) */
body.rail .side{width:var(--rail)}
body.rail .side .lbl,
body.rail .side .sect-hdr,
body.rail .side .chev{display:none}
body.rail .side .row{flex-direction:column;gap:4px;padding:16px 0;justify-content:center}
body.rail .side .row .mini{display:block}
body.rail .main{left:var(--rail)}
.mini{display:none;font-size:10px;line-height:1.1;text-align:center;max-width:64px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-muted)}
.row{
display:flex;align-items:center;gap:16px;
padding:0 24px;height:40px;margin:0 8px;border-radius:10px;
color:var(--ui-text);text-decoration:none;cursor:pointer;
font-size:14px; border:0; background:none; width:calc(100% - 16px);
text-align:left; font-family:inherit;
}
.row:hover{background:var(--ui-bg-accented)}
.row[aria-current="true"]{background:var(--ui-bg-accented);font-weight:600}
.row[aria-current="true"] .ic{color:var(--ui-primary)}
.row .lbl{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chev{transition:transform .18s;width:18px;height:18px;color:var(--ui-text-muted)}
.chev.open{transform:rotate(90deg)}
.sect-hdr{
padding:16px 24px 6px;font-size:14px;font-weight:600;color:var(--ui-text);
display:flex;align-items:center;gap:8px;
}
/* Nested SUB-GROUP inside a section (e.g. Laborer Mobilization > Research).
Indented + smaller so the hierarchy is unambiguous; a left rule ties its
children to the subheading. */
.subgrp-hdr{
padding:10px 24px 4px 40px; font-size:12px; font-weight:600;
color:var(--ui-text-muted); display:flex; align-items:center; gap:7px;
}
.subgrp-hdr .ic{width:15px;height:15px}
.subgrp{ margin-left:32px; border-left:1px solid var(--ui-border); }
.subgrp .row{ padding-left:14px; margin-left:4px; width:calc(100% - 24px); }
/* In the desktop RAIL the labels/subheadings hide, so drop the indent+rule too
(otherwise the icon column looks broken). */
body.rail .side .subgrp-hdr{display:none}
body.rail .side .subgrp{margin-left:0;border-left:0}
body.rail .side .subgrp .row{padding-left:0;margin-left:8px;width:calc(100% - 16px)}
.sep{height:1px;background:var(--ui-border);margin:8px 12px}
.badge{
font-size:10px;padding:2px 6px;border-radius:4px;
background:var(--ui-bg-accented);color:var(--ui-text-muted);font-weight:600;
}
.kids{overflow:hidden}
.kids[hidden]{display:none}
/* ── MAIN ───────────────────────────────────────────────── */
.main{
position:fixed; top:calc(var(--header) + var(--sat)); left:var(--drawer); right:0; bottom:0;
background:var(--ui-bg-elevated); overflow:auto;
}
.stage{position:relative;height:100%;display:grid;place-items:center;padding:16px}
/* the live app-profile slot — fills the stage; the custom element is absolutely
positioned inset:0 by the bundle's own CSS contract */
.slot{position:absolute;inset:0;display:none}
.stage.live .slot{display:block}
.stage.live .card{display:none}
/* corner markers are layout scaffolding — hide them once a real map is mounted */
.stage.live .corner{display:none}
.slot > *{display:block;position:absolute;inset:0;width:100%;height:100%}
.loading{
position:absolute;inset:0;display:grid;place-items:center;
font-size:13px;color:var(--ui-text-muted);background:var(--ui-bg-elevated);z-index:1;
}
.card{
background:var(--ui-bg);border:1px solid var(--ui-border);border-radius:12px;
box-shadow:var(--shadow-2); padding:28px; max-width:560px; width:100%;
}
.card h1{font-size:20px;margin-bottom:6px}
.card p{color:var(--ui-text-muted);font-size:14px;line-height:1.6}
.card code{background:var(--ui-bg-accented);padding:1px 5px;border-radius:4px;font-size:12px}
.corner{
position:absolute;font-size:10px;color:var(--ui-text-muted);
background:var(--ui-bg);border:1px solid var(--ui-border);
padding:3px 7px;border-radius:6px;pointer-events:none;
}
.tl{top:12px;left:12px} .tr{top:12px;right:12px}
.bl{bottom:12px;left:12px} .br{bottom:12px;right:12px}
/* ── MOBILE: collapsed = COMPLETELY GONE, hamburger reopens as overlay ── */
.scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:19;display:none}
@media (max-width:1023px){
.main{left:0 !important}
/* ⭐ MOBILE DRAWER OVERLAYS EVERYTHING — including the header (the reference
layout behaves this way; ours previously UNDER-lapped it).
Three changes make that true:
top:0 start at the very top, not below the header
z-index:35 above the header (30) and the scrim (19)
padding-top:... keep content clear of the status-bar/cutout strip, since
we no longer inherit the header's offset
Consequence: the drawer's own brand block now sits at the TOP of the panel
rather than hidden behind the header, and the hamburger is covered while the
drawer is open (tap the scrim or a profile to close). */
.side{
top:0; height:100%; z-index:35;
padding-top:calc(var(--sat) + 8px);
transform:translateX(-100%); transition:transform .22s cubic-bezier(.4,0,.2,1);
box-shadow:var(--shadow-2); width:var(--drawer) !important;
}
/* Scrim must also cover the header so the overlay reads as one layer. */
.scrim{z-index:34}
body.mopen .side{transform:none}
body.mopen .scrim{display:block}
/* on mobile the rail never applies — collapsed means GONE (saves space) */
body.rail .side .lbl, body.rail .side .sect-hdr, body.rail .side .chev{display:revert}
body.rail .side .row{flex-direction:row;gap:16px;padding:0 24px;height:40px;justify-content:flex-start}
body.rail .side .row .mini{display:none}
/* mobile drawer is always full-width, so restore the nested indent the rail dropped */
body.rail .side .subgrp-hdr{display:flex}
body.rail .side .subgrp{margin-left:32px;border-left:1px solid var(--ui-border)}
body.rail .side .subgrp .row{padding-left:14px;margin-left:4px;width:calc(100% - 24px)}
/* Mobile KEEPS the legend (the fieldset should still read as a labelled group)
but shortens it to "View" and scales the whole control down so it fits a
390px header alongside the logo and the ⋮ menu. */
.viewsel > legend{display:block;font-size:8px;letter-spacing:.5px;padding:0 5px}
.viewsel{padding:0 6px 1px}
/* Labels stay VISIBLE on mobile beside the icon — an icon alone does not say
what a pane holds, and these are per-profile names ("Posters"), not generic
view types you could guess from a glyph. This used to be display:none to save
header width; the space is bought back by shrinking the type and padding
instead, which costs legibility far less than losing the word entirely. */
.seg button span{display:inline;font-size:10.5px}
.seg button{padding:5px 6px;gap:4px}
.seg button .ic{width:15px;height:15px}
/* Never let a long pane name push the fieldset into the logo or the ⋮ menu. */
.seg button span{max-width:78px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.brand img{height:30px;max-width:150px}
}
@media (max-width:420px){ .brand img{height:28px;max-width:140px} }
</style>
</head>
<body class="">
<header class="hdr">
<button class="iconbtn" id="burger" aria-label="Toggle menu"></button>
<div class="brand">
<img src="./assets/brand/doxa-maps-logo-dark-with-text.png" alt="Doxa Maps" />
</div>
<div class="spacer"></div>
<fieldset class="viewsel">
<legend><span class="lg-full">Select View</span><span class="lg-short">View</span></legend>
<div class="seg" id="seg" role="group" aria-label="Select view"></div>
</fieldset>
<!-- Locale switcher: mirrors dx-life's LanguageSwitcher (flag + nativeName,
current language as the trigger, others in a dropdown) but sets
profile-config.locale instead of routing to /es, /fr … -->
<div class="langsel" id="langsel">
<button class="langtrigger" id="langtrigger" aria-haspopup="listbox" aria-expanded="false">
<span class="flag" id="langflag" aria-hidden="true"></span>
<span class="langname" id="langname"></span><span class="langcode" id="langcode"></span>
<span class="caret" aria-hidden="true"></span>
</button>
<ul class="langmenu" id="langmenu" role="listbox" hidden></ul>
</div>
<div class="appmenuwrap">
<button class="iconbtn" id="appmenu" aria-label="More" aria-haspopup="menu" aria-expanded="false"></button>
<!-- Overflow menu. The three-dots button previously did NOTHING; it now hosts
the language picker on narrow screens so the header stays uncrowded. -->
<div class="appmenu" id="appmenupanel" hidden>
<div class="appmenu-sect">Language</div>
<ul class="langmenu-inline" id="langmenu-inline" role="listbox"></ul>
</div>
</div>
</header>
<nav class="side" id="side" aria-label="Map profiles">
<!-- Logo lives HERE on mobile: the header is too tight for it, but the open
drawer has room to show it properly. Hidden on desktop, where the header
carries the logo instead. -->
<div class="side-brand">
<img src="./assets/brand/doxa-maps-logo-dark-with-text.png" alt="Doxa Maps" />
</div>
<div class="side-nav" id="side-nav"></div>
</nav>
<div class="scrim" id="scrim"></div>
<main class="main">
<div class="stage" id="stage">
<!-- Live app-profile mounts here. ONE at a time (WebGL budget). -->
<div class="slot" id="slot"></div>
<!-- Placeholder shown for profiles not yet wired -->
<div class="card" id="ph">
<h1 id="pt">Research Map</h1>
<p id="pd"></p>
</div>
<span class="corner tl">top-left</span>
<span class="corner tr">top-right</span>
<span class="corner bl">bottom-left</span>
<span class="corner br">bottom-right</span>
</div>
</main>
<script type="module">
// ── MAPBOX TOKEN ────────────────────────────────────────────────────────────
// This shell mounts bundles directly, so it must resolve a token itself — the
// 6-rung ladder in the GENERATED per-profile pages does not run here. Without
// this the app renders on a static host and every basemap is blank, because
// token.local.js is a LOCAL staging file that is deliberately never deployed.
//
// 1. window.DOXA_TOKEN — token.local.js, local staging only
// 2. ?tk= URL param — shared links
// 3. window.MAP_TOKEN_URL — a host page overriding the endpoint
// 4. ./api/maps/token.json — the static endpoint the build writes
//
// Rung 4 is RELATIVE so it resolves under whatever root the folder is served
// from — a Pages site, a self-hosted source-control service, or file-served
// staging — without being rebuilt for each. A 404 is a normal outcome (no
// token configured); it must not stop the shell from mounting, so the fetch
// fails soft and the bundles fall through to their own ladder.
let SHELL_TOKEN = ''
{
const qp = new URLSearchParams(location.search).get('tk')
SHELL_TOKEN = (typeof window.DOXA_TOKEN === 'string' && window.DOXA_TOKEN) || qp || ''
if (!SHELL_TOKEN) {
const url = window.MAP_TOKEN_URL || './api/maps/token.json'
try {
const r = await fetch(url)
if (r.ok) {
const ct = r.headers.get('content-type') || ''
const data = ct.includes('json') ? await r.json() : await r.text()
SHELL_TOKEN = (typeof data === 'string' ? data.trim()
: (data && (data.token || data.tk || data.accessToken)) || '')
}
} catch (e) {
console.warn('[shell] no Mapbox token from', url, '—', e && e.message ? e.message : e)
}
}
}
// The pane-wrapper LAYOUT, nested inside this one: app-wrapper-material owns the
// chrome (header, side menu, View fieldset); pane-wrapper owns the stage inside it.
import { buildPaneColumn, PANE_GRID_STYLES } from '../pane-wrapper/pane-wrapper.js'
import { NAV, navForTarget } from './nav.js'
import { icon } from './icons.js'
import { SWITCHER_LANGUAGES, getLanguage, loadLocale, saveLocale } from './languages.js'
const $ = (s) => document.querySelector(s)
const body = document.body
/* ── responsive mode ─────────────────────────────────────── */
const mq = window.matchMedia('(max-width: 1023px)')
const isMobile = () => mq.matches
/* Desktop starts EXPANDED; collapse → rail. Mobile starts CLOSED (gone). */
let rail = false, mopen = false
function render(){
body.classList.toggle('rail', rail && !isMobile())
body.classList.toggle('mopen', mopen && isMobile())
}
$('#burger').innerHTML = icon('menu')
$('#appmenu').innerHTML = icon('dots')
$('#burger').onclick = () => { isMobile() ? (mopen = !mopen) : (rail = !rail); render() }
$('#scrim').onclick = () => { mopen = false; render() }
mq.addEventListener('change', () => { mopen = false; render() })
/* ══════════════════════════════════════════════════════════════════════
LOCALE — the ONE thing this wrapper owns and hands to every profile.
The bundle contract is `profile-config.locale` (ProfileLoader reads it,
normalises it, and drives the bundle's i18n global locale). So switching
language = re-mounting the active profile with a new locale in its config.
No router, no locale URLs, no i18n framework in the wrapper itself.
══════════════════════════════════════════════════════════════════════ */
let locale = loadLocale()
function applyDir(){
// RTL locales flip the whole wrapper, matching dx-life's dir handling.
document.documentElement.dir = getLanguage(locale)?.dir === 'rtl' ? 'rtl' : 'ltr'
}
function drawLang(){
const cur = getLanguage(locale)
$('#langflag').textContent = cur?.flag || ''
$('#langname').textContent = cur?.nativeName || locale
// 2-letter code: the compact text fallback when the flag alone is ambiguous
$('#langcode').textContent = (cur?.code || locale).toUpperCase()
const opts = SWITCHER_LANGUAGES.map(l => `
<li role="option" aria-selected="${l.code===locale}">
<button data-lang="${l.code}" aria-selected="${l.code===locale}">
<span class="flag" aria-hidden="true">${l.flag}</span>
<span>${l.nativeName}</span>
</button>
</li>`).join('')
// SAME options in two places: the desktop header pill, and the ⋮ overflow menu
// used on mobile. One source, so they can never drift apart.
$('#langmenu').innerHTML = opts
const inline = $('#langmenu-inline'); if (inline) inline.innerHTML = opts
;[$('#langmenu'), inline].filter(Boolean).forEach(host =>
host.querySelectorAll('[data-lang]').forEach(btn =>
btn.onclick = () => {
const next = btn.dataset.lang
closeLang(); closeAppMenu()
if (next === locale) return
locale = next
saveLocale(locale)
applyDir()
drawLang()
paint() // re-mount so the profile picks up the new locale
}))
}
function openLang(){ $('#langmenu').hidden = false; $('#langtrigger').setAttribute('aria-expanded','true') }
function closeLang(){ $('#langmenu').hidden = true; $('#langtrigger').setAttribute('aria-expanded','false') }
$('#langtrigger').onclick = (e) => {
e.stopPropagation()
$('#langmenu').hidden ? openLang() : closeLang()
}
document.addEventListener('click', (e) => {
if (!$('#langsel').contains(e.target)) closeLang()
if (!$('.appmenuwrap').contains(e.target)) closeAppMenu()
})
/* ── The ⋮ overflow menu. It previously did NOTHING; now it is where the
language picker lives on mobile, freeing real header space. ── */
function openAppMenu(){ $('#appmenupanel').hidden = false; $('#appmenu').setAttribute('aria-expanded','true') }
function closeAppMenu(){ $('#appmenupanel').hidden = true; $('#appmenu').setAttribute('aria-expanded','false') }
$('#appmenu').onclick = (e) => {
e.stopPropagation()
$('#appmenupanel').hidden ? openAppMenu() : closeAppMenu()
closeLang()
}
/* ── the 3 view tabs (Map / Grid / Tree) ─────────────────── */
const VIEWS = [
{ id:'map', label:'Map', icon:'map' },
{ id:'list', label:'List', icon:'list' }, // Tabulator data view (not built yet)
{ id:'tree', label:'Tree', icon:'tree' },
]
let view = 'map'
// The tabs are PER-PROFILE, not a fixed app-wide set. A profile that declares
// `panes` shows exactly those and no others — upg-100 has a map and a poster wall,
// so it shows two tabs, not three with a dead one. A profile with no `panes` key
// falls back to the default VIEWS, so nothing else changes.
// (Showing a tab for a pane that does not exist is a promise the app cannot keep —
// the same reasoning as feature-flagging the edge strips off when there is nothing
// below to scroll to.)
function viewsForProfile(){
// Explicit per-profile view set: a profile may declare exactly which of the app-wide
// views (map/list/tree) it offers. One entry => no switcher (drawSeg hides the
// "Select View" fieldset). This is the parameterised tab control.
const only = PROFILES[active]?.views
if (Array.isArray(only) && only.length){
const set = VIEWS.filter(v => only.includes(v.id))
if (set.length) return set
}
const declared = PROFILES[active]?.panes
if (!Array.isArray(declared) || declared.length < 2) return VIEWS
return declared.map(p => ({
id: p.id,
label: p.label,
// Reuse the matching VIEWS icon when the pane id lines up; fall back to the
// generic list glyph so a new pane kind never renders icon-less.
icon: (VIEWS.find(v => v.id === p.id) || {}).icon || 'list',
}))
}
function drawSeg(){
const views = viewsForProfile()
// No switcher for a single-view profile — the "Select View" fieldset only earns its
// space when there is a choice to make.
const _sel = document.querySelector('.viewsel')
if (_sel) _sel.style.display = views.length <= 1 ? 'none' : ''
// If the active view is not one this profile has (e.g. you were on Tree, then
// switched to a two-pane profile), fall back to its first pane.
if (!views.some(v => v.id === view)) view = views[0].id
$('#seg').innerHTML = views.map(v =>
`<button data-v="${v.id}" aria-pressed="${v.id===view}">${icon(v.icon)}<span>${v.label}</span></button>`
).join('')
$('#seg').querySelectorAll('button').forEach(b =>
b.onclick = () => {
view = b.dataset.v
drawSeg()
// PANE PROFILE: the tab is a NAVIGATION control, not a remount — the target
// pane is already alive in the same scrolling column, so animate to it and
// do NOT tear the map down. Any other profile keeps the original behaviour.
if (live?.column){ live.column.scrollTo(view); return }
paint()
})
}
/* ── sidebar: sections (always open) + folders (collapsible) + loose items ── */
const target = isMobile() ? 'mobile' : 'desktop'
const tree = navForTarget(NAV, target)
const open = new Map(tree.filter(n=>n.kind==='folder').map(n=>[n.id, !!n.open]))
// The profile shown on first load (design note: the prayer map is the first map they see).
// Declared HERE, above its first use — a `const` further down the module would be in
// the temporal dead zone at this point and throw.
const DEFAULT_PROFILE = 'prayer-progress'
// Which profile is showing is URL STATE, not just a variable — otherwise a reload
// always snaps back to the default (reported: "reload loses which map I was on").
// The hash is used rather than a path so the app stays drop-portable: it works
// identically on file://, a CDN subpath, Railway, and inside Capacitor, none of
// which can be relied on to rewrite server-side routes.
function profileFromHash(){
const id = decodeURIComponent((location.hash || '').replace(/^#\/?/, '')).trim()
return PROFILES[id] ? id : DEFAULT_PROFILE
}
// NOTE: seeded with the default here and RESOLVED from the hash at boot (bottom of
// this module). profileFromHash() reads PROFILES, which is declared further down —
// calling it at this point would hit the temporal dead zone and throw, blanking the
// sidebar. That exact bug has already been shipped once; do not "simplify" this back.
let active = DEFAULT_PROFILE
function rowHTML(n, cur){
return `<button class="row" data-id="${n.id}" aria-current="${n.id===cur}">
${icon(n.icon)}<span class="lbl">${n.label}</span><span class="mini">${n.label.split(' ')[0]}</span>${n.badge ? `<span class="badge">${n.badge}</span>` : (n.target==='desktop' ? '<span class="badge">desktop</span>' : '')}
</button>`
}
function drawSide(){
let h = ''
// The `sep` is a divider BETWEEN groups — the first group must not carry a
// leading one (a rule under the header with nothing above it to divide).
let firstGroup = true
for (const n of tree){
if (n.kind === 'item'){ h += rowHTML(n, active); firstGroup = false; continue }
if (n.kind === 'section'){
if (!firstGroup) h += '<div class="sep"></div>'
h += `<div class="sect-hdr"><span>${n.label}</span></div>`
firstGroup = false
// A section's children may be plain items OR a nested 'subgroup'
// (e.g. Laborer Mobilization > Research > Research Map).
for (const c of n.children){
if (c.kind === 'subgroup'){
h += `<div class="subgrp-hdr"><span>${c.label}</span></div>
<div class="subgrp">${c.children.map(g => rowHTML(g, active)).join('')}</div>`
} else {
h += rowHTML(c, active)
}
}
continue
}
if (n.kind === 'folder'){
const o = open.get(n.id)
if (!firstGroup) h += '<div class="sep"></div>'
firstGroup = false
h += `<button class="row" data-folder="${n.id}">
${icon(n.icon)}<span class="lbl">${n.label}</span>
${/* desktop badge removed — the folder label already conveys it */''}
<span class="chev ${o?'open':''}">${icon('chevron')}</span>
</button>
<div class="kids" ${o?'':'hidden'}>${n.children.map(c=>rowHTML(c,active)).join('')}</div>`
}
}
$('#side-nav').innerHTML = h
$('#side-nav').querySelectorAll('[data-folder]').forEach(b =>
b.onclick = () => { open.set(b.dataset.folder, !open.get(b.dataset.folder)); drawSide() })
$('#side-nav').querySelectorAll('[data-id]').forEach(b =>
b.onclick = () => {
active = b.dataset.id
setHash(active) // survive reload + make the view shareable
if (isMobile()){ mopen = false; render() } // mobile: pick → drawer closes
drawSeg() // tab set is PER-PROFILE — redraw it for the new one
drawSide(); paint()
})
}
/* ── main pane: placeholder standing in for the mounted app-profile ── */
function label(id){
for (const n of tree){
if (n.id === id) return n.label
const c = n.children?.find(x => x.id === id)
if (c) return c.label
}
return id
}
/* ══════════════════════════════════════════════════════════════════════
PROFILE REGISTRY — which plugin-designs are wired to a real bundle.
`tag` the custom element the bundle registers
`src` the built bundle (self-contained, relative → drop-portable)
`config` the profile-config JSON the element takes
Anything not listed here still renders the placeholder card.
══════════════════════════════════════════════════════════════════════ */
const PROFILES = {
// ⭐ PRAYER PROGRESS = the doxa-simple-map bundle's PRAYER instance.
// CORRECTED 2026-07-30: this previously pointed at the RESEARCH-map bundle,
// which was architecturally wrong — the research map is a 6-tab research tool
// and belongs under the Research section, not Prayer Mobilization.
// simple-map ships three named single-tab instances (prayer / engagement /
// adoption) declared in plugin-designs/doxa-simple-map/simple-map.instances.json.
// The 'prayer' one is titled "Prayer Map" and is the right map for this slot.
// NOTE its element tag is <doxa-map>, NOT <doxa-simple-map> — the bundle
// registers both, and the generated instance pages use <doxa-map>. Config is
// copied from that page's BASE_CONFIG so we match the shipped contract exactly.
// NOTE: each entry's `src` is mirrored in the DEEP-LINK PRELOAD map at the top
// of this file — update both when adding/moving a bundle.
'prayer-progress': {
tag: 'doxa-map',
src: './doxa-maps/doxa-simple-map/doxa-simple-map.js',
views: ['map'], // prayer map is one view — no Map/List/Tree sub-tabs
// ⚠️ colorStrategy MUST be 'prayerProgress', NOT 'prayer'.
// The strategy registry derives its keys from FILENAMES via import.meta.glob:
// library/colors/prayer-progress.js -> 'prayerProgress'. There is no prayer.js
// anywhere (nor a profile-local one in plugin-designs/doxa-simple-map/src/colors/),
// so 'prayer' does not resolve — and getColorStrategy() SILENTLY FALLS BACK to
// languageFamily. No error, just a completely different palette, which is why
// this map lost the white-ringed "glowing" prayer pins.
// simple-map.instances.json ships 'prayer' and has the same latent bug; we pass
// the correct key explicitly here rather than depend on that file.
config: { profile:'doxa-simple-map', instanceId:'embed-prayer', dataSource:'pray-tools',
tabs:[{ id:'prayer', colorStrategy:'prayerProgress', legend:'prayer', popup:'prayer' }] },
},
// MY UPG 100 LIST — the personal list-of-100 profile. Tag + config read off its
// own generated page (BASE_CONFIG), not guessed: profile 'upg-100-list' (NOT the
// folder name 'my-upg-100-list'), and the element tag is my-upg-100-list-map.
'upg-100-list': {
tag: 'my-upg-100-list-map',
src: './doxa-maps/my-upg-100-list/my-upg-100-list.js',
config: { profile:'upg-100-list', dataSource:'pray-tools', instanceId:'wrapper-upg-100' },
// PANES — this profile is a FAMILY of views over one subject, laid out on a
// grid. `at` is [row, col]; the grid is a DEFAULT of 2×2 but is neither fixed
// nor a ceiling — a profile declares only the panes it actually has, so a
// map-only profile (no `panes` key at all) stays exactly as it was.
//
// MOTION CONTRACT (the core design decision):
// • VERTICAL neighbours = ONE SCROLLABLE PAGE — both panes live at once.
// • HORIZONTAL movement = a FLY-TO — the target column loads on arrival.
// At most TWO panes are ever alive, which is what preserves the WebGL budget
// that `teardown()` has always protected.
// Each pane may carry its own `config` — a SIBLING PROFILE in the same bundle
// (index.js does import.meta.glob('./*.vue'), so poster-wall.vue registers
// itself). Same tag, same src, different `profile` value: one download serves
// both panes and they share data/clusters.json rather than fetching twice.
panes: [
{ id:'map', label:'Map', at:[0,0] },
{ id:'list', label:'Prayer Lists', at:[1,0],
config: { profile:'poster-wall', instanceId:'wrapper-upg-100' } },
],
},
// COUNTRY POSTERS — one poster per country, the same shape as upg-100-list:
// a map pane and a poster pane, two SIBLING PROFILES in one bundle.
// The nav has carried this id since 2026-07-29 as a placeholder card; this entry
// is what turns it into a real app.
//
// Tag + profile names come from the bundle itself, NOT guessed:
// folder plugin-designs/country-posters/ -> src below
// element <country-posters-map> (index.js customElements.define)
// profiles countries-map.vue / country-poster-wall.vue
// The profile value is the .vue FILENAME, which is why 'countries-map' is right
// here even though the folder is 'country-posters' — the same trap as
// upg-100-list vs my-upg-100-list above.
'countries-map': {
tag: 'country-posters-map',
src: './doxa-maps/country-posters/country-posters.js',
config: { profile:'countries-map', dataSource:'pray-tools', instanceId:'wrapper-countries' },
// No "Posters below ↓" pill — the header's Map | Posters switcher already says
// the second pane exists, so the pill is a redundant control sitting over the map.
paneHint: false,
panes: [
{ id:'map', label:'Map', at:[0,0] },
{ id:'poster', label:'Posters', at:[1,0],
config: { profile:'country-poster-wall', instanceId:'wrapper-countries' } },
],
},
// KEY CITIES — where the unengaged people groups live. The nav has carried
// 'key-city-posters' as a placeholder since 2026-07-29; this makes it real.
'key-city-posters': {
tag: 'key-cities-map',
src: './doxa-maps/key-cities/key-cities.js',
config: { profile:'cities-map', instanceId:'wrapper-key-cities' },
paneHint: false,
panes: [
{ id:'map', label:'Map', at:[0,0] },
{ id:'list', label:'Cities', at:[1,0],
config: { profile:'cities-list', instanceId:'wrapper-key-cities' } },
{ id:'poster', label:'Posters', at:[2,0],
config: { profile:'city-poster-wall', instanceId:'wrapper-key-cities' } },
{ id:'data', label:'Data', at:[3,0],
config: { profile:'data-tables', instanceId:'wrapper-key-cities' } },
],
},
// CITIES WITH INTERNET — connectivity where the unengaged people groups live.
// The nav has carried this id as a placeholder since 2026-07-29; this entry is
// what turns it into a real app. `profile` is the .vue FILENAME, not the folder.
// Ships WITH `panes` (map/list/operators below) — the pane grid is live.
// ALL PEOPLE GROUPS — every group on earth (peoplegroups.org) around the DOXA
// set: black doxa · red frontier · yellow unreached · green reached. Replaces
// the long-standing placeholder.
'all-people-groups': {
tag: 'all-people-groups',
src: './doxa-maps/all-people-groups/all-people-groups.js',
config: { profile:'all-people-groups', instanceId:'wrapper-all-people-groups' },
},
'cities-with-internet': {
tag: 'cities-internet-map',
src: './doxa-maps/cities-internet/cities-internet.js',
config: { profile:'connectivity-map', instanceId:'wrapper-cities-internet' },
paneHint: false,
panes: [
{ id:'map', label:'Map', at:[0,0] },
{ id:'summary', label:'Summary', at:[1,0],
config: { profile:'connectivity-list', instanceId:'wrapper-cities-internet' } },
{ id:'tables', label:'Data', at:[2,0],
config: { profile:'data-tables', instanceId:'wrapper-cities-internet' } },
],
},
// GOSPEL RESOURCES — which DX languages have scripture, and which have none.
// Data is the eBible×DX join (public, no key). GRN audio/film rows are `unknown`
// until the GRN key lands — see apps/gospel-resources/BLOCKER-grn-api-key.md.
'gospel-resources': {
tag: 'gospel-resources-map',
src: './doxa-maps/gospel-resources/gospel-resources.js',
// MAP IS PANE 0. Brief: "mapping out the gospel resources is super important
// for me… all these are missing maps." The map is the app's ONE WebGL pane;
// the list and tables below it are DOM.
config: { profile:'resources-map', instanceId:'wrapper-gospel-resources' },
paneHint: false,
panes: [
{ id:'map', label:'Map', at:[0,0] },
{ id:'summary', label:'Summary', at:[1,0],
config: { profile:'resources-list', instanceId:'wrapper-gospel-resources' } },
{ id:'tables', label:'Data', at:[2,0],
config: { profile:'data-tables', instanceId:'wrapper-gospel-resources' } },
{ id:'charts', label:'Charts', at:[3,0],
config: { profile:'charts', instanceId:'wrapper-gospel-resources' } },
],
},
// The RESEARCH map — the full multi-tab research tool, under Research.
'research-map': {
tag: 'doxa-research-map',
src: './doxa-maps/doxa-research-map/doxa-research-map.js',
views: ['map'], // research map is one view — no Map/List/Tree sub-tabs
config: { profile:'research-map', dataSource:'pray-tools', instanceId:'wrapper-research' },
},
// VENDORED PRE-BUILT BUNDLES (public/external/): self-contained prebuilt IIFEs.
// Read token+API from the shell like native bundles; fetch /api/people-groups.
// Under public/external/ so `bun run build` (wipes public/doxa-maps/) skips them.
'research-dashboard': {
tag: 'research-dashboard-map',
src: './external/research-dashboard-map.js',
views: ['map'], // one view — hide the Map/List/Tree switcher for the dashboard
config: { profile:'research-dashboard', dataSource:'pray-tools', instanceId:'wrapper-research-dashboard' },
},
// PRODUCTION UPG Neighbor = the focused lang-cluster MAP: all unreached plotted and
// grouped into language clusters by geography, each red cluster ringed by the GREEN
// reached neighbours matching its language — the send-strategy-by-language-and-geography
// view. Clicking a cluster drills into the flower view.
'upg-neighbor': {
tag: 'lang-cluster',
src: './external/upg-neighbor.js',
views: ['map'],
config: { profile:'lang-cluster', dataSource:'pray-tools', instanceId:'wrapper-upg-neighbor' },
},
// EXPERIMENTAL UPG Neighbor = the unified hub with ALL lenses (cockpit / flower /
// lang-cluster / send) and its own internal switcher. Same bundle, different element.
'upg-neighbor-lab': {
tag: 'upg-neighbors',
src: './external/upg-neighbor.js',
views: ['map'],
config: { profile:'upg-neighbors', dataSource:'pray-tools', instanceId:'wrapper-upg-neighbor-lab' },
},
}
/* ══════════════════════════════════════════════════════════════════════
MOUNT REGISTRY — enforces the teardown contract centrally (not per widget).
Only ONE live profile at a time, so only one WebGL context is held.
Research findings applied:
· NO KeepAlive/caching — a cached element never unmounts and leaks contexts
· teardown = remove the element from the DOM → its disconnectedCallback runs
· browsers cap WebGL at ~16 contexts (fewer on mobile); silent blanking on overflow
══════════════════════════════════════════════════════════════════════ */
const loaded = new Set()
let live = null // { el, id }
// Pane-grid CSS ships with the nested layout, so the two layouts stay separable.
{
const st = document.createElement('style')
st.textContent = PANE_GRID_STYLES
document.head.appendChild(st)
}
function teardown(){
if (!live) return
// Removing the element fires its disconnectedCallback, where the bundle
// disposes its own map (map.remove() releases the WebGL context in GL JS v3).
// Remove EVERY pane element — a pane column mounts one per pane, and each one's
// disconnectedCallback is what releases its resources (a map's WebGL context).
for (const e of (live.els || [live.el])) { try { e.remove() } catch(_){} }
// Tear the pane column down too — it owns DOM and a scroll listener that would
// otherwise leak on every profile swap.
if (live.column) { try { live.column.destroy() } catch(_){} }
live = null
}
async function mount(id){
const spec = PROFILES[id]
teardown() // always release before acquiring
const stage = $('#stage'), slot = $('#slot')
// Only the MAP view mounts a live map bundle. Switching to List/Tree tears the
// map down (releasing its WebGL context) — the view tabs ARE a resource gate.
// RESOURCE GATE — unchanged for single-mount profiles: leaving Map tears the map
// down to release its WebGL context. But a PANE profile keeps its column alive:
// there the non-map view is a SIBLING PANE in the same scrolling page, not a
// replacement, and the two-pane cap already bounds the context budget.
const isPaneProfile = Array.isArray(spec?.panes) && spec.panes.length > 1
if (view !== 'map' && !isPaneProfile){ stage.classList.remove('live'); paintOtherView(); return }
if (!spec){ stage.classList.remove('live'); paintPlaceholder(); return }
stage.classList.add('live')
slot.innerHTML = '<div class="loading">Loading map…</div>'
try{
if (!loaded.has(spec.src)){
await import(spec.src) // side-effect: registers the tag
loaded.add(spec.src)
}
await customElements.whenDefined(spec.tag)
const el = document.createElement(spec.tag)
// ⚠️ THIS BUNDLE'S CONTRACT IS THE **ATTRIBUTE**: ProfileLoader JSON.parse()s
// the `profile-config` attribute. Do NOT also assign el.profileConfig — Vue's
// custom-element wrapper reflects the property back onto the attribute using
// String(obj), which overwrites valid JSON with an unquoted/`[object Object]`
// form and trips "[ProfileLoader] Invalid profile-config JSON".
// (The general rule from the research — pass objects as DOM *properties* —
// applies to elements whose contract IS a property. Here it is the attribute.)
// The Mapbox token goes INSIDE the profile-config JSON as `tk` (the bundle's
// token ladder reads it there) — not as a separate attribute.
const cfg = { ...spec.config }
if (SHELL_TOKEN) cfg.tk = SHELL_TOKEN
// THE LOCALE HAND-OFF: ProfileLoader reads profile-config.locale, normalises it
// and drives the bundle's i18n from it. This one field is the whole contract.
cfg.locale = locale
// ASSET BASE: bundles fetch tables/vendor (Tabulator) from `${assetsBase}/...`. Served under
// a subpath (e.g. /plugin-assets/doxa-maps-spa/), a bare '/assets' or './assets' misresolves,
// so hand each bundle an ABSOLUTE base derived from THIS shell's own document — correct at any depth.
if (!cfg.assetsBase) cfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
// QA corner widget ON for the go-base SPA surface (opt-in flag; off for partner embeds).
cfg.qa = (new URLSearchParams(location.search).get('qa') === '1') /* QA opt-in via ?qa=1; widget exists only in DOXA_QA=1 builds */; cfg.qaLabel = spec.label || cfg.profile
el.setAttribute('profile-config', JSON.stringify(cfg))
// ── PANE COLUMN vs SINGLE MOUNT ─────────────────────────────────────────
// If the profile declares 2+ vertical panes, build the scrollable column (the
// pane-wrapper layout, nested inside this one) and mount the live element into
// its FIRST pane. Otherwise keep the original single-mount behaviour EXACTLY —
// a profile with no `panes` key is untouched by any of this.
slot.innerHTML = ''
const column = buildPaneColumn(spec, { host: slot })
if (column){
// Pane 0 gets the element already built above; every OTHER pane that declares
// its own `config` gets a sibling element from the SAME bundle (same tag, same
// src — one download) with a different `profile`. A pane with no config keeps
// its placeholder, so an unbuilt view never blocks the column.
column.mountEl(column.panes[0].id)?.appendChild(el)
const extras = [el]
for (const pane of column.panes.slice(1)){
if (!pane.config) continue
const pel = document.createElement(spec.tag)
const pcfg = { ...spec.config, ...pane.config }
if (SHELL_TOKEN) pcfg.tk = SHELL_TOKEN
pcfg.locale = locale
if (!pcfg.assetsBase) pcfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
pcfg.qa = (new URLSearchParams(location.search).get('qa') === '1') /* QA opt-in via ?qa=1; widget exists only in DOXA_QA=1 builds */; pcfg.qaLabel = spec.label || pcfg.profile
pel.setAttribute('profile-config', JSON.stringify(pcfg))
column.mountEl(pane.id)?.appendChild(pel)
extras.push(pel)
}
live = { el, id, column, els: extras }
// Keep the View fieldset in sync with where the user has scrolled to, so the
// tabs are a readout as well as a control.
column.onScroll(() => {
const cur = column.current()
if (cur && cur !== view){ view = cur; drawSeg() }
})
// ── CROSS-PANE ACTION: "Show on map" ────────────────────────────────────
// The poster wall dispatches a window CustomEvent when a card's "Show on
// map" is clicked, but nothing ever listened — the button fired into the
// void. The two profiles are separate custom elements in separate shadow
// roots, so a window event is the right seam; only the sending half existed.
//
// The shell owns the pane column, so it is the natural listener: scroll to
// the MAP pane, then hand the selection down to the map element. Scoped by
// instanceId so several embeds on one page never cross-talk.
//
// THE EVENT NAME IS PER-APP, not one global constant. Each poster wall
// dispatches its own scoped name so two walls on one page cannot drive each
// other's map. A profile declares it via `showOnMapEvent`; the 100-list keeps
// its original name so nothing about that app changes.
const SHOW_EVENTS = ['doxa-upg-100-list:show-on-map', 'doxa-country-posters:show-on-map']
if (column._showOnMap) {
for (const evName of SHOW_EVENTS) window.removeEventListener(evName, column._showOnMap)
}
column._showOnMap = (ev) => {
const d = ev?.detail || {}
// The map is pane 0 by convention (the element built above mounts there).
const mapPaneId = column.panes[0]?.id
if (mapPaneId) column.scrollTo(mapPaneId)
// Forward to every live element in this column; the map profile picks it
// up and selects the zone. A DESELECT arrives as listId === null, which
// is what lets the wall's green highlight be toggled off.
for (const target of (live?.els || [el])) {
try {
target.dispatchEvent(new CustomEvent('doxa:select-zone', {
detail: { listId: d.listId ?? null, label: d.label ?? null },
bubbles: false,
}))
} catch (_) { /* an element still booting simply misses it */ }
}
}
for (const evName of SHOW_EVENTS) window.addEventListener(evName, column._showOnMap)
} else {
slot.appendChild(el)
live = { el, id }
}
}catch(err){
slot.innerHTML =
`<div class="loading">Could not load <code>${spec.tag}</code><br>
<small>${String(err.message || err)}</small></div>`
console.error('[app-wrapper] mount failed:', err)
}
}
function paintPlaceholder(){
$('#pt').textContent = label(active)
$('#pd').innerHTML =
`No bundle wired for <code>${active}</code> yet — this pane is where its
app-profile custom element will mount.<br><br>
<b>view:</b> <code>${view}</code> &nbsp;·&nbsp; <b>target:</b> <code>${target}</code>
&nbsp;·&nbsp; <b>sidebar:</b> <code>${isMobile() ? (mopen?'overlay':'hidden') : (rail?'rail':'expanded')}</code>`
}
/* LIST view → the Tabulator data table (not built yet).
TREE view → the semantic tree (not built yet). */
function paintOtherView(){
const v = VIEWS.find(x => x.id === view)
$('#pt').textContent = `${label(active)} — ${v.label}`
$('#pd').innerHTML = view === 'list'
? `The <b>List</b> view will render this profile's data as a
<b>Tabulator</b> table — sortable/filterable rows over the same dataset the
map plots. <i>Not built out yet.</i><br><br>
Note: switching off <b>Map</b> tears the map down, releasing its WebGL
context — the view tabs double as the resource gate.`
: `The <b>Tree</b> view will render this profile's data as a semantic
hierarchy (region → country → people group). <i>Not built out yet.</i>`
}
function paint(){ mount(active) }
// Write the hash WITHOUT firing hashchange back at us (replaceState is silent),
// so selecting a map doesn't re-enter the boot path and remount the bundle twice.
function setHash(id){
const h = '#/' + id
if (location.hash !== h) history.replaceState(null, '', h)
}
// Resolve the deep-link BEFORE the first draw so the sidebar highlights the right
// row and the correct bundle mounts once (PROFILES is defined by now — see the TDZ
// note at the DEFAULT_PROFILE declaration).
active = profileFromHash()
setHash(active) // normalise "" / a bad id → a real hash
// Browser back/forward, and hand-edited URLs, should change the map.
addEventListener('hashchange', () => {
const next = profileFromHash()
if (next === active) return // no-op guard: never remount for nothing
active = next
drawSeg() // tab set is PER-PROFILE — redraw it for the new one
drawSide(); paint()
})
applyDir(); drawLang(); drawSeg(); drawSide(); render(); paint()
</script>
</body>
</html>