Merge main into bundler-2/work

This commit is contained in:
doxa-maps-88655c70 2026-09-23 23:48:10 +00:00
commit be77ea954c
69 changed files with 1455 additions and 530 deletions

View file

@ -22,6 +22,8 @@ and a change here can break other maps.
`import … from '@map/components/ResearchMapSideMenu.vue'`
- **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application.
`import … from '@map/components/SideMenuDrawer.vue'`
- **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
`import … from '@map/components/ViewSelect.vue'`
- **BarChart** — BarChart.vue — THE SHARED HORIZONTAL BAR CHART (hand-rolled SVG, zero dependencies).
`import … from '@map/components/charts/BarChart.vue'`
- **PinHint** — PinHint.vue — the conversion coach-mark (bottom-center of any map).

View file

@ -62,7 +62,16 @@ useShadowStyles(
'.seg button{ position:relative; display:flex; align-items:center; gap:6px;' +
' border:0; background:none; color:var(--ui-text-muted,#6d766e);' +
' padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;' +
' transition:color .15s; font-family:inherit; }' +
' transition:color .15s; font-family:inherit;' +
// ONE LINE PER TAB. Ported from the shell's own mobile rule (app-wrapper-material
// index.html, max-width:960px: white-space:nowrap + ellipsis) — the extraction that
// created this component copied the DESKTOP metrics verbatim but left that hardening
// behind, so the leaf and the shell disagreed on narrow viewports.
// MEASURED at 390px with the label "Prayer Resources": the fieldset is width-capped
// by the header, the TEXT wrapped inside the button (28 -> 43px) and the strip grew
// 45 -> 60px, while the shell held 45px. The row of tabs never wrapped; only the
// words inside one did. Ellipsis is the better failure than a taller header.
' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }' +
'.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; }' +

View file

@ -148,7 +148,24 @@ function positionMenu() {
// no edge-sharing placement is reachable — fall back to the hard clamp, since an
// unreachable menu is worse than a detached one. That last case cannot mislead: the
// trigger is not on screen to look detached FROM.
const width = Math.round(Math.min(r.width, vw - EDGE * 2))
// ── ★ THE MENU IS NOT THE TRIGGER'S WIDTH (card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) ──
// DRIVER: "Make the filters very NARROW, but then the dropdown menu that opens up can be as wide
// as you want it to be… so you can fit MORE OF THEM ON ONE ROW WITHOUT WORD WRAPPING."
//
// THE COUPLING THAT MADE THAT IMPOSSIBLE was this one line: the menu was `min(r.width, …)` — the
// TRIGGER's measured width. The two boxes have OPPOSITE constraints and were sharing one number:
// - the CLOSED TRIGGER competes for a shared row, so every px it takes is a px another axis
// cannot have; it should be as small as its label allows.
// - the OPEN MENU is transient and overlays everything (position:fixed, z-index above the
// world), so its width costs the layout NOTHING — no sibling reflows around it.
// Sizing them together meant narrowing the trigger also narrowed the menu, truncating the option
// labels that are the whole reason a narrow trigger is acceptable. So they are separated here.
//
// The menu is therefore max(trigger, MENU_MIN) and still capped to the viewport. It is a FLOOR,
// not a fixed size: a trigger WIDER than the floor (a consumer that raised --dx-filter-w) keeps
// the old edge-matched look, so this cannot regress a host that was already happy.
const MENU_MIN = 240
const width = Math.round(Math.min(Math.max(r.width, MENU_MIN), vw - EDGE * 2))
const triggerOnScreen = r.right > EDGE && r.left < vw - EDGE
let left
if (!triggerOnScreen) {
@ -361,16 +378,49 @@ onBeforeUnmount(() => {
--dx-filter-w is a TOKEN, not a literal, for the same reason every colour here is: this
component is shared by people-groups-grid, big-picture AND the research-dashboard, so a
consumer that needs a different trigger size sets the token at :host instead of forking the
component. 168px is sized to the Driver's brief — "just big enough to express the first or
second word" — measured against the real labels: it shows "Region: North Am…", i.e. the axis
name plus the first word or two, which is what makes the row scannable without reading it. */
component. 168px was sized to an earlier brief — "just big enough to express the first or
second word".
── ★ 168px -> 128px: THE TRIGGER AND THE MENU ARE SIZED SEPARATELY NOW ──
(card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) DRIVER, four times: "the filters are way too
wide. Make the filters very NARROW… so you can fit MORE OF THEM ON ONE ROW WITHOUT WORD WRAPPING.
And that way we can decrease the thickness of the headers."
The trigger no longer has to carry the SELECTED VALUE legibly, because positionMenu() now floors
the open menu at 240px independent of this width (see its MENU_MIN note). That is what unlocks
the narrowing: the full value is always readable in the menu, so the closed trigger only has to
carry the AXIS NAME plus an ellipsis hint — which is all the Driver ever reads it for.
128px IS MEASURED, NOT PICKED — and the first number tried (116px) was measured WRONG, which is
why the measurement is recorded here rather than the conclusion alone. The chrome budget is:
2px border + 16px horizontal padding (2 x --dx-filter-pad-x, trimmed 12 -> 8 with this card) +
6px gap + 12px chevron = 36px. So a 128px trigger leaves 92px for text.
Every axis label rendered at 0.875rem/system-ui and measured live:
Bloc 29.8 · Status 44.6 · Prayer 45.2 · People 46.5 · Region 47.9 · Country 55.6 ·
Religion 55.7 · Language 69.1 · Engagement 89.3
116px was chosen first, on the reasoning that "Language" (69.1) was the longest of the SEVEN
CURRENTLY SHIPPED and fit its 80px budget with room to spare. That was true and still wrong:
this component is SHARED, and this file's own history records an eighth "engagement/Status" axis
that has been present before. At 89.3px "Engagement" overflowed the 116px budget by 9.3px — the
axis NAME would have ellipsised, which is the one truncation that must never happen (a trigger
reading "Engageme…" is unscannable, and unlike a truncated VALUE it has no menu to recover it).
128px fits the longest KNOWN label with 2.7px to spare and is still a 24% cut from 168px.
The floor is a TYPE metric: it moves only if the labels or the font do — and the font is not
allowed to move (below). If an axis longer than "Engagement" is ever added, re-measure rather
than letting it truncate.
★ THE FONT-SIZE IS NOT A LEVER. The Driver has rejected shrinking it twice ("super tiny text
that cannot be read"). Width, padding and TRIGGER-label truncation are the only tools; 0.875rem
on .filter-dropdown__trigger is load-bearing and must not be traded for density. */
.filter-dropdown { position: relative; flex: 0 0 auto;
width: var(--dx-filter-w, 168px); min-width: var(--dx-filter-w, 168px); max-width: var(--dx-filter-w, 168px); }
width: var(--dx-filter-w, 128px); min-width: var(--dx-filter-w, 128px); max-width: var(--dx-filter-w, 128px); }
/* Mobile keeps the SAME fixed-box contract — only the number shrinks. The old rule here was
`flex:1 1 100%`, which full-widthed the trigger and wrapped the row; that is the growth bug in
its most extreme form, so it cannot come back as a "mobile exception". */
@media (max-width: 576px) { .filter-dropdown {
width: var(--dx-filter-w-sm, 140px); min-width: var(--dx-filter-w-sm, 140px); max-width: var(--dx-filter-w-sm, 140px); } }
width: var(--dx-filter-w-sm, 108px); min-width: var(--dx-filter-w-sm, 108px); max-width: var(--dx-filter-w-sm, 108px); } }
/* ★ THE TRIGGER'S HEIGHT IS THE FILTER ROW'S HEIGHT, and it was the last unattributed term
(card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED — "the filter row is way too thick").
MEASURED at 1600x900 standalone, the filters band's 67px decomposes with nothing left over:
@ -382,14 +432,17 @@ onBeforeUnmount(() => {
padding 8->5, the selections row 24->18). The trigger was never touched, and at 30px it is the
single largest contributor — which is why the band kept measuring ~67 no matter how much was
shaved off the others. Vertical padding 6 -> 4 takes the trigger to 26 and the band to 63.
★ VERTICAL ONLY. The horizontal 12px is UNCHANGED and must stay: `check:filter-width` asserts
every trigger is exactly --dx-filter-w (168px) before and after selecting the longest option,
and the label's ellipsis budget is that width minus this padding and the chevron. Touching the
horizontal value would move a gate that exists to keep the centred stack from re-flowing.
Tokenized so a consumer can trade density back without forking the component, matching how
--dx-filter-w already handles the width. */
★ THE HORIZONTAL PADDING IS NOW A LEVER TOO — 12px -> 8px, and the earlier "must stay" note it
replaces was over-stated (card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU). That note said
touching it "would move a gate", having read check:filter-width as asserting a literal 168px.
It does not: the gate records every trigger's width with nothing selected and requires the SAME
widths after selecting the longest option — it asserts STABILITY, not a number. A uniform
narrowing moves both arrays together and the gate stays green (verified by running it).
The 8px is spent where it is worth most: it is 8px of the 72px text budget handed back to the
axis label, which is what lets the trigger reach 116px without clipping "Language".
Tokenized for the same reason as the width — a consumer trades density back without forking. */
.filter-dropdown__trigger {
width: 100%; padding: var(--dx-filter-pad-y, 4px) 12px; cursor: pointer; display: flex; justify-content: space-between;
width: 100%; padding: var(--dx-filter-pad-y, 4px) var(--dx-filter-pad-x, 8px); cursor: pointer; display: flex; justify-content: space-between;
/* Pairs its own foreground with its own background for the same reason as the menu:
`color: inherit` over a light background rendered near-white-on-white inside the
cap-4 dark header. Background and text are set together, never half-inherited. */

View file

@ -124,7 +124,7 @@ A zero-dependency Fuse.js-shaped matcher (so no npm dep is added to the shared
```js
const fuse = new Fuse(list, {
keys: ['name', 'country_code.label', 'religion.label'], // dotted paths
threshold: 0, // 0 = exact substring only; >0 = fuzzy subsequence
threshold: 0, // 0 = exact substring only; >0 = fuzzy (k-errors edit distance, Fuse-parity)
minMatchCharLength: 3, // shortest run counted as a match
})
fuse.search(term) // -> [{ item, matches: [{ key, indices: [[start,end], …] }] }]

View file

@ -13,7 +13,7 @@
* with { keys, threshold, includeMatches, ignoreLocation, minMatchCharLength }. We reproduce
* exactly the parts of that contract the grid relies on:
* - keys: array of dotted paths ('name', 'country_code.label', …) read from each item
* - threshold: 0 = exact substring only; >0 = fuzzy (subsequence) allowed
* - threshold: 0 = exact substring only; >0 = fuzzy (k-errors edit distance, Fuse-parity)
* - minMatchCharLength: shortest run counted as a match
* - .search(term) -> [{ item, matches:[{ key, indices:[[start,end], …] }] }]
* The `matches[].indices` drive the <span class="search-highlight"> wrapping in the grid,
@ -43,29 +43,57 @@ function exactIndices(haystack, needle) {
return out
}
// Fuzzy (subsequence) match: every char of `needle` appears in order in `haystack`.
// Returns the runs of consecutive matched positions, or null if not all chars matched.
function fuzzyIndices(haystack, needle) {
// ── Fuzzy match, FUSE-PARITY (2026-09-23) ────────────────────────────────────────────────
// This used to be a strict SUBSEQUENCE matcher ("every char of needle appears in order"), and
// that is NOT what Fuse.js does. The difference bit in production: DX-life's real Fuse
// (threshold 0.4, ignoreLocation) tolerates TYPOS -- an extra trailing letter ("Bhutann"),
// a substituted one ("Indoa") -- because bitap scores k errors against the pattern length.
// A subsequence can only ever DROP haystack chars; an EXTRA needle char always fails it.
// Measured on the live dashboard: 'Bhutan' -> 13 rows, 'Bhutann' -> 0. The Driver's test.
//
// So this is the standard approximate-substring DP (Sellers/Ukkonen "k-errors" matching):
// best edit distance of the needle against ANY window of the haystack (free start, free end),
// matching iff errors/needle.length <= threshold -- the same score contract Fuse applies with
// ignoreLocation:true. Highlight indices are the best-scoring window's char span.
function approxIndices(haystack, needle, threshold) {
const h = haystack.toLowerCase()
const n = needle.toLowerCase()
const runs = []
let hi = 0
let runStart = -1
let runEnd = -1
for (let ni = 0; ni < n.length; ni++) {
const ch = n[ni]
let found = -1
for (let k = hi; k < h.length; k++) {
if (h[k] === ch) { found = k; break }
const m = n.length
if (!m || !h.length) return null
const maxErr = Math.floor(threshold * m)
if (maxErr <= 0) return null
// dp[j] = min errors matching the whole prefix n[0..i] ending at text position j.
// Row 0 is all zeros: the match may START anywhere (that is the "search" variant).
let prev = new Array(h.length + 1).fill(0)
let cur = new Array(h.length + 1)
for (let i = 1; i <= m; i++) {
cur[0] = i
for (let j = 1; j <= h.length; j++) {
const sub = prev[j - 1] + (n[i - 1] === h[j - 1] ? 0 : 1)
cur[j] = Math.min(sub, prev[j] + 1, cur[j - 1] + 1)
}
if (found === -1) return null
if (runStart === -1) { runStart = runEnd = found }
else if (found === runEnd + 1) { runEnd = found }
else { runs.push([runStart, runEnd]); runStart = runEnd = found }
hi = found + 1
;[prev, cur] = [cur, prev]
}
if (runStart !== -1) runs.push([runStart, runEnd])
return runs
let best = Infinity, bestEnd = -1
for (let j = 1; j <= h.length; j++) if (prev[j] < best) { best = prev[j]; bestEnd = j }
if (best > maxErr) return null
const start = Math.max(0, bestEnd - m)
return [[start, bestEnd - 1]]
}
/**
* Low-level predicate shared with the dashboard's search matcher (useDashboardShell.js):
* does `text` match `term` under the DX-life Fuse contract (exact substring first, then
* k-errors fuzzy when term.length >= minMatchCharLength)? ONE matcher, every search surface.
*/
export function fuzzyMatches(text, term, { threshold = 0.4, minMatchCharLength = 3 } = {}) {
const raw = String(text ?? '')
const q = String(term ?? '').trim()
if (!q) return true
if (!raw) return false
if (raw.toLowerCase().includes(q.toLowerCase())) return true
if (threshold === 0 || q.length < minMatchCharLength) return false
return approxIndices(raw, q, threshold) !== null
}
export default class Fuse {
@ -89,12 +117,8 @@ export default class Fuse {
if (typeof raw !== 'string' || !raw) continue
let indices = exactIndices(raw, q)
if (indices.length === 0 && !exactOnly) {
const fz = fuzzyIndices(raw, q)
if (fz) {
// keep only runs at least minMatchCharLength long (mirrors Fuse's minMatch)
const kept = fz.filter(([s, e]) => e - s + 1 >= Math.min(this.minMatchCharLength, q.length))
if (kept.length) indices = kept
}
const fz = approxIndices(raw, q, this.threshold)
if (fz) indices = fz
}
if (indices.length) matches.push({ key, indices })
}

View file

@ -70,7 +70,7 @@ const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
// Named styles: the 12-style pack. A profile-config style of 'ink' /
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
import { resolveNamedStyle } from '../styles/registry.js'
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
/** The live engine global for a provider, or undefined if not yet loaded. */
function engineGlobal(provider) {
@ -238,6 +238,12 @@ export function useMapInstance({
// A pinned value is therefore never lowered, only raised. Leave it undefined to
// get the pure dynamic floor.
minZoom = undefined,
// ★ skipWorldFloor: a GLOBE host opts out of the desktop no-duplicate-world minZoom floor.
// That floor exists to stop a wide mercator viewport showing the world twice; on the globe
// projection there is no second world -- the planet is a sphere -- so the floor only steals
// zoom-out range (measured: it pinned the dashboard at ~1.45 when the whole point of a globe
// is pulling back to see it whole). The pinned minZoom (or the engine floor) still applies.
skipWorldFloor = false,
maxZoom = 18,
pitch = 0,
bearing = 0,
@ -351,6 +357,7 @@ export function useMapInstance({
// up automatically on an orientation/breakpoint flip into mobile.
const isDesktop = () => typeof window !== 'undefined' && window.innerWidth >= 1024
const floorFor = (W, H) => {
if (skipWorldFloor) return (typeof minZoom === 'number') ? minZoom : MIN_ZOOM_FLOOR
if (!isDesktop()) return MIN_ZOOM_FLOOR // mobile/tablet — no floor
const f = computeWorldMinZoom(W, H, maxZoom, southCutoff)
return (typeof minZoom === 'number') ? Math.max(minZoom, f) : f
@ -547,6 +554,19 @@ export function useMapInstance({
}
map.value.on('style.load', () => {
// ── NAMED-STYLE PAINT PATCH (e.g. 'galaxy': blue water on a black ground) ──────────────
// style.load is the ONLY correct moment: setPaintProperty before the style document has
// parsed throws, and re-applying here also survives a later setStyle() — the event fires
// again, so a style swap cannot silently drop the patch.
// Nothing happens for styles with no `paint` in the registry, so every existing map is
// byte-for-byte unchanged.
if (_named.paint && map.value) {
const hit = applyStylePaint(map.value, _named.paint)
// Log what the LOADED style actually had — layer ids differ between Mapbox and MapLibre
// and have moved across Mapbox style versions, so this is the only trustworthy record
// of which ids are real. A patch that matched nothing is a silent no-op otherwise.
console.info('[useMapInstance] style paint applied (' + provider + '):', hit.join(', ') || 'NONE MATCHED')
}
// Resize after style.load in a rAF: container layout may not have settled yet,
// especially when the panel was hidden (display:none) at startup.
requestAnimationFrame(() => {

View file

@ -380,7 +380,17 @@ const VTL_SHADOW_CSS = `
one viewport. The clipping now belongs to .vtl-host, which owns a real scrollbar, so the content
is both contained AND reachable. Dropping paint costs the collapse nothing: paint never
contributed reflow isolation, only clipping. */
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout; }
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
/* ★ NO containment AT ALL on .vtl-content (BUG-FULLSCREEN-MODALS-UNDERSIZED-INSIDE-THE-CAP5-SHELL,
measured 2026-09-23). contain:paint was dropped earlier for the scroll bug; contain:layout
stayed -- but LAYOUT containment ALSO makes this element the containing block for every
position:fixed descendant (CSS Containment spec), so all six modal scrims hosted in the shell
(PosterDialog, DataTable .dt-scrim, LegendTools .lt-overlay, SemanticTreeLegend export
backdrop, both poster-wall .modal-scrim) resolved inset:0 to the CONTENT PANE: measured, a
fixed inset:0 probe here read x=256 w=1184 on a 1440 viewport, leaving the 256px nav rail
uncovered and CLICKABLE behind an open modal. Visibly almost right, interactively wrong.
The perf rationale it was kept for does not hold: when the nav collapses, this element's own
width changes, and a width change re-lays-out the subtree with or without containment. */
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). Measured in the cap-5
shell: the dashboard-plus-grid composite mounted here reported scrollHeight 3380 / clientHeight
769 — 2611px of real, mounted, populated content (the people-groups grid, h=2586) that NO element
@ -813,7 +823,17 @@ export default defineComponent({
one viewport. The clipping now belongs to .vtl-host, which owns a real scrollbar, so the content
is both contained AND reachable. Dropping paint costs the collapse nothing: paint never
contributed reflow isolation, only clipping. */
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout; }
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
/* ★ NO containment AT ALL on .vtl-content (BUG-FULLSCREEN-MODALS-UNDERSIZED-INSIDE-THE-CAP5-SHELL,
measured 2026-09-23). contain:paint was dropped earlier for the scroll bug; contain:layout
stayed -- but LAYOUT containment ALSO makes this element the containing block for every
position:fixed descendant (CSS Containment spec), so all six modal scrims hosted in the shell
(PosterDialog, DataTable .dt-scrim, LegendTools .lt-overlay, SemanticTreeLegend export
backdrop, both poster-wall .modal-scrim) resolved inset:0 to the CONTENT PANE: measured, a
fixed inset:0 probe here read x=256 w=1184 on a 1440 viewport, leaving the 256px nav rail
uncovered and CLICKABLE behind an open modal. Visibly almost right, interactively wrong.
The perf rationale it was kept for does not hold: when the nav collapses, this element's own
width changes, and a width change re-lays-out the subtree with or without containment. */
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). Measured in the cap-5
shell: the dashboard-plus-grid composite mounted here reported scrollHeight 3380 / clientHeight
769 — 2611px of real, mounted, populated content (the people-groups grid, h=2586) that NO element

View file

@ -631,6 +631,11 @@ const VIEWS = [
{ id:'tree', label:'Tree', icon:'tree' },
]
let view = 'map'
// Which profile the tab strip was last drawn FOR, so drawSeg can tell "same profile,
// user picked a tab" from "profile changed, reset to its first view". Declared with
// `var`-like hoisting in mind: it is read inside drawSeg, which only ever runs after
// this module body has executed, so a `let` here is safe (cf. DEFAULT_PROFILE below).
let segProfile = null
// 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
@ -666,7 +671,15 @@ function drawSeg(){
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
//
// ALSO fall back when the profile CHANGED, even if the old view id happens to exist
// here too. `view` is one global across profiles and 'map' is a valid id in nearly
// every one, so an id-existence test alone let a stale 'map' survive a switch into a
// profile whose FIRST pane is not the map (upg-100-list leads with Prayer Resources).
// The column always opens at scroll position 0, so the strip then painted a pressed
// tab that did not match the pane on screen. A new profile opens on its own first view.
if (!views.some(v => v.id === view) || segProfile !== active) view = views[0].id
segProfile = active
$('#seg').innerHTML = views.map(v =>
`<button data-v="${v.id}" aria-pressed="${v.id===view}">${icon(v.icon)}<span>${v.label}</span></button>`
).join('')
@ -826,10 +839,23 @@ const PROFILES = {
// (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.
// RESOURCES FIRST, MAP SECOND (Driver 2026-09-23). "By default the first tab
// shouldn't be the map. The first tab should be the prayer resources" — that pane
// already explains what a UPG-100 list IS at the top and shows the posters, so a
// new user needs no explanation for it; a choropleth of zones does. Stated by him
// as a general principle: "maps don't have to be primary. You can use lists and
// grids for the primary view and maps as a secondary view… then they transition
// into the map." Familiarity is the on-ramp.
//
// Both panes carry an EXPLICIT `config.profile`, including the map. The mount code
// used to rely on "pane 0 is the map" positionally — reordering under that rule
// would have mounted the map element into the poster pane. Naming the profile on
// every pane is what makes the order data rather than convention.
panes: [
{ id:'map', label:'Map', at:[0,0] },
{ id:'list', label:'Prayer Lists', at:[1,0],
{ id:'list', label:'Prayer Resources', at:[0,0],
config: { profile:'poster-wall', instanceId:'wrapper-upg-100' } },
{ id:'map', label:'Map', at:[1,0],
config: { profile:'upg-100-list', instanceId:'wrapper-upg-100' } },
],
},
// COUNTRY POSTERS — one poster per country, the same shape as upg-100-list:
@ -1046,13 +1072,25 @@ async function mount(id){
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)
// The element already built above goes to the pane that ASKED FOR ITS PROFILE;
// 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.
//
// WHY BY PROFILE, NOT BY INDEX. This used to be `panes[0]`, encoding "the map is
// pane 0" as a positional convention. That silently breaks the moment a profile
// wants a non-map pane first — upg-100-list now leads with its poster wall — and
// the failure is the worst kind: the MAP element mounts into the POSTER pane and
// both panes render the map. Panes that name no profile cannot match, so every
// other multi-pane profile still lands on pane 0 exactly as before.
const hostPane =
column.panes.find(p => p.config?.profile && p.config.profile === spec.config?.profile)
|| column.panes[0]
column.mountEl(hostPane.id)?.appendChild(el)
const extras = [el]
for (const pane of column.panes.slice(1)){
for (const pane of column.panes){
if (pane.id === hostPane.id) continue
if (!pane.config) continue
const pel = document.createElement(spec.tag)
const pcfg = { ...spec.config, ...pane.config }
@ -1092,8 +1130,11 @@ async function mount(id){
}
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
// The map is the pane the SHELL-BUILT element mounted into — resolved by
// profile, not by position. "Pane 0 is the map" stopped being true when
// upg-100-list put its poster wall first; under the old rule "Show on map"
// scrolled to the poster wall you clicked FROM, which looks like a dead button.
const mapPaneId = hostPane?.id || 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

View file

@ -83,6 +83,48 @@ export const STYLE_REGISTRY = [
mapbox: 'mapbox://styles/mapbox/navigation-night-v1',
maplibre: 'https://tiles.openfreemap.org/styles/dark',
bestFor: ['research-dashboard'] },
// ── GALAXY — black ground, BLUE water ────────────────────────────────────────────────────
// DRIVER 2026-09-23: "the map style needs to have the BLUE WATER with like the BLACK GALAXY
// background". The continents go to deep space; the oceans stay unmistakably water.
//
// ★ WHY THIS IS A BASE STYLE + A PAINT PATCH, NOT A HAND-AUTHORED style.json. Writing a whole
// style document would mean owning ~47 layers, a glyph endpoint and a sprite URL forever, and
// it would be MapLibre-only — breaking the registry's contract that every id resolves on BOTH
// engines. Instead both providers start from their existing near-black style and we repaint
// exactly the layers that carry water. The patch is small, auditable, and provider-symmetric.
//
// WHAT THE BASE STYLES ACTUALLY PAINT (measured 2026-09-23, not assumed):
// OpenFreeMap dark — background rgb(12,12,12); layer id `water` (fill) is rgb(27,27,29) and
// `waterway` (line) the same. i.e. the base style does not distinguish
// water from land AT ALL. That is precisely the gap this entry fills.
// Mapbox dark-v11 — its water layer ids are enumerated at runtime (they differ from
// MapLibre's and have changed across style versions), so `paint` lists
// CANDIDATES and the applier skips any id the loaded style lacks.
//
// THE BLUE: #2563a8 measures 3.19:1 against the rgb(12,12,12) ground — above the 3:1 WCAG
// non-text threshold, so the coastline is a real edge rather than a tonal hint. Darker,
// moodier blues were tried and measured first: #1b3a6b 1.74:1 and #1e4d8f 2.34:1 both fall
// UNDER 3:1, which is how an ocean ends up merely looking like slightly-less-black land.
/* ★ CORRECTED 2026-09-23 — THE FIRST GALAXY WAS A MISREADING OF THE ASK, and the Driver said so:
"I don't want a dark basemap, I didn't ask for one. I asked for a spherical GLOBE with a black
background — the galaxy, like the Mapbox map — with BLUE OCEANS like the Mapbox map, and the
land was NOT to be black."
The first attempt tinted a dark basemap (black land, recoloured water) — the night side of
Earth. What he described is the DAY side seen from space: NORMAL land colours, the default
blue oceans, and the blackness belongs to the SPACE BEHIND THE SPHERE, not to the ground.
So the style is now a normal-coloured basemap with no paint overrides at all — the galaxy is
not a property of the tiles. It is the void the globe floats in, which is the map CONTAINER's
background showing through outside the sphere (see DashboardMapPanel's .rd-map rule). */
{ id: 'galaxy', label: 'Galaxy', mood: 'the day side of Earth floating in space — normal land, blue oceans, black void behind the sphere',
mapbox: 'mapbox://styles/mapbox/standard',
// ★ FOUND, NOT AUTHORED (Driver: "I want it replaced with something you can find online").
// VersaTiles Colorful — a maintained, production-free, keyless style (280 layers) whose ocean
// is a real blue (#bfd9f2), the closest of the surveyed free styles to the Mapbox look. The
// alternatives measured and rejected: CARTO Voyager's water is pale teal #b0d0d6; OSM
// Americana has proper blue but its hosted tiles are a demo endpoint, not for production.
maplibre: 'https://tiles.versatiles.org/assets/styles/colorful/style.json',
bestFor: ['research-dashboard', 'my-upg-100-list'] },
]
const BY_ID = Object.fromEntries(STYLE_REGISTRY.map(s => [s.id, s]))
@ -92,16 +134,43 @@ const BY_ID = Object.fromEntries(STYLE_REGISTRY.map(s => [s.id, s]))
* · a registry NAME → that entry's per-provider style URL
* · a raw mapbox:// or http(s):// URL → passed through untouched (legacy behavior)
* · anything else / undefined → undefined (caller keeps its own default)
* Returns { style, lightPreset } — lightPreset only for named Standard variants.
* Returns { style, lightPreset, paint } — lightPreset only for named Standard variants,
* `paint` only for entries that patch the base style (see applyStylePaint).
*/
export function resolveNamedStyle(value, provider = 'mapbox') {
if (typeof value !== 'string' || !value) return { style: undefined, lightPreset: undefined }
if (typeof value !== 'string' || !value) return { style: undefined, lightPreset: undefined, paint: undefined }
const entry = BY_ID[value]
if (entry) {
return provider === 'maplibre'
? { style: entry.maplibre, lightPreset: undefined }
: { style: entry.mapbox, lightPreset: entry.lightPreset }
? { style: entry.maplibre, lightPreset: undefined, paint: entry.paint }
: { style: entry.mapbox, lightPreset: entry.lightPreset, paint: entry.paint }
}
if (/^(mapbox:\/\/|https?:\/\/)/.test(value)) return { style: value, lightPreset: undefined }
return { style: undefined, lightPreset: undefined }
if (/^(mapbox:\/\/|https?:\/\/)/.test(value)) return { style: value, lightPreset: undefined, paint: undefined }
return { style: undefined, lightPreset: undefined, paint: undefined }
}
/**
* Apply a registry entry's `paint` patch to a LOADED map, skipping any layer the style lacks.
*
* ★ WHY SKIP-IF-ABSENT IS THE WHOLE DESIGN. Layer ids are a property of the STYLE DOCUMENT, not
* of the engine — Mapbox and MapLibre name water differently, and Mapbox has renamed its own ids
* across style versions. Hardcoding one id and calling setPaintProperty on it throws and kills
* the rest of the patch. Probing getLayer() first turns a wrong guess into a no-op, so a list
* covering both engines is safe to apply to either.
*
* Returns the ids it actually matched, so a caller can log what the loaded style really had
* rather than what we hoped it had.
*/
export function applyStylePaint(map, paint) {
if (!map || !Array.isArray(paint)) return []
const applied = []
for (const p of paint) {
if (!p || !p.layer || !p.prop) continue
try {
if (!map.getLayer(p.layer)) continue
map.setPaintProperty(p.layer, p.prop, p.value)
applied.push(p.layer + '.' + p.prop)
} catch (_) { /* a style that has the layer but not the property: skip, never throw */ }
}
return applied
}

View file

@ -147,12 +147,24 @@ const FACET_TO_PROP = {
// to join it: imb_gsec is a real row field with 5 {value,label} pairs and no value-vs-label
// cross-collision, so valueMatches() selects the same rows whichever vocabulary arrives.
gospel_access: 'gospelAccess',
// ── THE TWO FORMERLY-DERIVED-ONLY AXES, wired at last (2026-09-23) ──
// These were the "STILL DELIBERATELY UNMAPPED" pair above: their values are DERIVED
// ("Hinduism" from the religion code's first letter; "Indo-European" from the
// imb_language_family label or the ISO lookup), so no fetched row field could match them
// and mapping them would have filtered the grid to ZERO. The grid now runs the SAME
// derivations itself (people-groups-grid.vue DERIVED_AXES, importing the same @map/colors
// + @map/data modules dashboard.config.js reads), so the value the dashboard emits and the
// value the grid derives come from one owner and byte-match. With that in place, the top
// tiers of the Religion and Language trees drive the grid like every other axis.
religion_family: 'religionFamily',
language_family: 'languageFamily',
}
// Empty string (not null) is the grid's documented "no filter" value for every axis, so a
// cleared facet must RESET its prop rather than leave the previous value stuck.
const filter = reactive({ query: '', wagfRegion: '', wagfBlock: '', rop1: '', religion: '', countryCode: '',
primaryLanguage: '', engagementStatus: '', cluster: '', peopleGroup: '', gospelAccess: '' })
primaryLanguage: '', engagementStatus: '', cluster: '', peopleGroup: '', gospelAccess: '',
religionFamily: '', languageFamily: '' })
function onDashboardFilter({ filterSet = {}, searchTerm = '' } = {}) {
filter.query = searchTerm || ''
@ -276,6 +288,8 @@ const sections = [
:cluster="filter.cluster"
:people-group="filter.peopleGroup"
:gospel-access="filter.gospelAccess"
:religion-family="filter.religionFamily"
:language-family="filter.languageFamily"
:show-search="false" :show-filters="false" :show-total="false" />
</template>
</StackLayout>

View file

@ -274,38 +274,69 @@ const { scrimRef, scrimStyle } = useViewportScrim(exportState)
// all stay — printPoster below still uses posterUrl(), and all three print paths still
// share the exportState HUD for progress/errors.
// ─── SHARED PRINT-WINDOW MECHANISM ────────────────────────────────────────────
// ─── SHARED PRINT MECHANISM ───────────────────────────────────────────────────
// ONE mechanism, reused by all three "Print …" actions (poster / list / card
// deck) rather than three ad-hoc window.opens. Extracted from the poster path,
// which already carries the load-bearing fix documented below.
//
// ★ NO `noopener` HERE, AND THAT IS THE WHOLE FIX (2026-09-23). `noopener` makes window.open()
// return null BY SPECIFICATION — severing the opener relationship is exactly what the flag does,
// so there is no handle to hand back. With it, `w` was always null, the document.write preview
// below was UNREACHABLE FOR EVERY USER, and every click fell through to a raw-content fallback.
// MEASURED in a default (non-headless-flagged) browser: window.open('','_blank','noopener') -> null,
// the identical call without the flag -> a usable handle. I first attributed the null to this
// headless box; it is not environmental, it is what the flag means.
// The window we write into carries same-origin markup we generate ourselves, so the opener
// reference it regains is our own — there is nothing hostile for `noopener` to protect against here.
// deck) rather than three ad-hoc implementations.
//
// `bodyHtml` is the FULL <html> document string (caller owns <head>/<style>/<body>). The caller's
// content is responsible for its own print trigger — pass `autoprint:true` to have this helper
// append the `window.focus();window.print()` bootstrap (used by markup with no <img onload> of
// its own to hang the call off).
//
// ── HISTORY, kept because the lesson outlives the code that taught it ──
// This helper used to call window.open(). An earlier build passed `noopener`, which makes
// window.open() return null BY SPECIFICATION — severing the opener relationship is exactly what
// the flag does, so there was no handle to write into. Every click silently fell through to a
// raw-content fallback, for every user. MEASURED: window.open('','_blank','noopener') -> null;
// the identical call without the flag -> a usable handle. It was first blamed on the headless box;
// it was not environmental, it was what the flag means.
// The iframe below makes that class of failure unreachable — there is no popup to block and no
// opener flag to get wrong — but the lesson stands: a flag that returns null by spec will look
// exactly like an environment problem.
// ★ IN-PLACE (2026-09-23) — a hidden same-page IFRAME, not a popup window.
//
// WHY THIS CHANGED, so nobody "restores" the popup as a regression fix: an EARLIER Driver ask
// wanted a preview window ("show the poster in your browser to help you print it"), and this
// helper was built around window.open() to serve it. The Driver has now REVERSED that: the print
// dialog must open DIRECTLY, with no second browser window. That is a decision change, not a bug.
//
// An iframe is the right vehicle because it keeps the ENTIRE existing contract: callers still pass
// one full <html> document string, we still document.write() it, and the markup's own
// `<img onload> -> window.print()` still fires — only now `window` inside that document is the
// iframe's contentWindow, so the dialog belongs to THIS page. All three print paths (poster /
// list / card deck) stay on this one mechanism.
//
// It also deletes a whole failure mode: there is no popup blocker on an iframe, so the
// "popup blocked -> fall back to opening the raw image" branch every caller carried is now dead.
// The returned object keeps a truthy `.document`-less shape only so existing `if (!w)` guards stay
// harmless; callers should no longer rely on a popup-blocked null.
function openPrintWindow(title, bodyHtml, { autoprint = false } = {}) {
const w = window.open('', '_blank', 'width=980,height=1100')
if (!w) return null // popup blocked — caller decides the fallback
w.document.open()
w.document.write(bodyHtml)
const frame = document.createElement('iframe')
// Off-screen rather than display:none — a display:none iframe does not lay out in every engine,
// and a zero-height layout can print a blank first page.
frame.setAttribute('aria-hidden', 'true')
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:0;border:0'
document.body.appendChild(frame)
const doc = frame.contentDocument
if (!doc) { frame.remove(); return null } // no document -> caller keeps its fallback
doc.open()
doc.write(bodyHtml)
if (autoprint) {
// Appended AFTER the caller's markup so it runs once the document is parsed —
// matches the <img onload> timing the poster path already relies on.
w.document.write(
doc.write(
'<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print()},150)})</' + 'script>')
}
w.document.close()
return w
doc.close()
// Reclaim the node once the dialog has been dismissed. `afterprint` is the honest signal; the
// timeout is a floor for engines that never fire it, so a click can never leak a frame per press.
const drop = () => { if (frame.parentNode) frame.remove() }
try { frame.contentWindow.addEventListener('afterprint', () => setTimeout(drop, 0)) } catch { /* cross-doc guard */ }
setTimeout(drop, 60000)
return frame.contentWindow
}
// Escape the handful of characters that break out of an HTML text node or a "..." attribute.
@ -336,20 +367,33 @@ async function printPoster(list) {
const title = `Doxa UPG-100 poster — ${escapeHtml(list.label || list.id)}`
const w = openPrintWindow(title,
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
'<style>@page{size:auto;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
// PORTRAIT, explicitly. `size:auto` let the browser choose, and it chooses LANDSCAPE for a
// square: the poster art is 3ft x 3ft (aspect-ratio:1/1, see the wall template below), so an
// auto page has no reason to prefer the tall axis. The list and card-deck print paths in this
// same file already pin `size:letter portrait`; the poster path was the odd one out.
// (Driver: "print to a vertical piece of printer paper".)
'<style>@page{size:letter portrait;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
'body{display:flex;align-items:center;justify-content:center}' +
'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}' +
'@media print{body{height:auto}img{max-height:none;width:100%}}</style></head>' +
// A SQUARE on a PORTRAIT sheet must fit by its SHORTER axis. The old print rule
// (`max-height:none;width:100%`) scaled the square to the full page WIDTH, which on portrait
// letter is taller than the printable height -> the poster overflowed onto a second page.
// Constrain BOTH axes and let object-fit letterbox it inside the sheet instead.
'@media print{body{height:auto}' +
'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>' +
'<body><img src="' + url + '" alt="' + title + '"' +
' onload="setTimeout(function(){window.focus();window.print()},150)"' +
' onerror="document.body.innerHTML=\'<p style=&quot;font:16px system-ui;padding:24px&quot;>Poster not available yet.</p>\'">' +
'</body></html>')
if (!w) { // popup blocked → open the image tab
if (!w) {
// No longer "popup blocked" — an iframe cannot be blocked. This is now only reachable if the
// frame has no contentDocument at all (a sandboxed/detached host), so the honest fallback is
// the poster itself rather than a pop-up instruction the user cannot act on.
window.open(url, '_blank', 'noopener')
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
setTimeout(() => { exportState.value = null }, 900); return
}
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
exportState.value = { label: list.label, kind: 'Poster', phase: 'error',
@ -433,11 +477,13 @@ async function printList100(list) {
'<th>Religion</th><th></th></tr></thead><tbody>' + rowsHtml + '</tbody></table>' +
'</body></html>', { autoprint: true })
if (!w) {
// Not a pop-up blocker any more (this prints via a same-page iframe). Only a host that
// denies us a contentDocument lands here, so say something the user can act on.
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
return
}
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
@ -505,11 +551,12 @@ async function printCardDeck(list) {
'<div class="deck">' + cardsHtml + '</div>' +
'</body></html>', { autoprint: true })
if (!w) {
// See the note in the list path: an iframe cannot be pop-up blocked.
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
return
}
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',

View file

@ -15,8 +15,9 @@
* regions-dashboard could reuse it instead of copying it a third time.
*
* Copied conventions (see layouts/app-wrapper-material/index.html, 'upg-100-list'):
* • panes: { id:'map', label:'Map', at:[0,0] } and
* { id:'list', label:'Prayer Lists', at:[1,0], config:{ profile:'poster-wall' } }
* • panes: { id:'list', label:'Prayer Resources', at:[0,0], config:{ profile:'poster-wall' } }
* and { id:'map', label:'Map', at:[1,0] } — RESOURCES FIRST; see the note
* on `panes` below for why the familiar view is the on-ramp to the map.
* • per-pane element config = { ...spec.config, ...pane.config } — same tag, same
* bundle, different `profile`; one download serves both panes.
* • ONE shared instanceId for both panes (as the shell does) so the poster wall's
@ -53,9 +54,21 @@ const spec = computed(() => ({
id: 'upg-100-mfe',
tag: TAG,
config: { profile: 'upg-100-list', dataSource: cfg.value?.dataSource || 'pray-tools', instanceId: instanceId.value },
// RESOURCES FIRST, MAP SECOND (Driver 2026-09-23). "By default the first tab
// shouldn't be the map. The first tab should be the prayer resources" — that pane
// already explains what a UPG-100 list IS at the top and shows the posters, so it
// needs no explanation; a choropleth of zones does. Generalised by him as a design
// principle: "maps don't have to be primary. You can use lists and grids for the
// primary view and maps as a secondary view… then they transition into the map."
//
// ORDER LIVES IN `at[0]`, NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by
// at[0] (pane-wrapper.js), while ViewSelect renders this array VERBATIM. Swapping
// only one of the two desynchronises the tab strip from the scroll column — the
// strip would read "Prayer Resources | Map" while scroll position 0 still held the
// map. Both are swapped here, and `view` below is initialised from the same sort.
panes: [
{ id: 'map', label: 'Map', at: [0, 0], config: { profile: 'upg-100-list' } },
{ id: 'list', label: 'Prayer Lists', at: [1, 0], config: { profile: 'poster-wall' } },
{ id: 'list', label: 'Prayer Resources', at: [0, 0], config: { profile: 'poster-wall' } },
{ id: 'map', label: 'Map', at: [1, 0], config: { profile: 'upg-100-list' } },
],
// The View switcher lives in this element's own header, so the layout's
// "… below ↓" pill would be a second, redundant control (shell design note 2026-08-01).
@ -63,7 +76,24 @@ const spec = computed(() => ({
}))
const stage = ref(null)
const view = ref('map')
// DERIVED from the pane order, never a hardcoded id. This was `ref('map')`, which
// silently became wrong the moment the panes were reordered: the column scrolled to
// the resources pane while the strip still painted "Map" as pressed. Sorting by
// at[0] here is the SAME rule buildPaneColumn applies, so the pressed tab and the
// pane at scroll-position 0 cannot disagree.
const firstPaneId = computed(() => {
const col = spec.value.panes
.filter(p => (p.at?.[1] ?? 0) === 0)
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
return col[0]?.id || 'list'
})
const viewRaw = ref(null)
// Until the column exists, the active view IS the first pane — so the strip paints a
// pressed tab on the very first render rather than flashing with nothing selected.
const view = computed({
get: () => viewRaw.value || firstPaneId.value,
set: (v) => { viewRaw.value = v },
})
let column = null
let offScroll = null

View file

@ -45,13 +45,25 @@ import Fuse from '@map/components/search/fuzzy.js'
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import { countrySlug } from './src/countries.js'
// ── DERIVED family axes (BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ──
// The Religion/Language trees' TOP TIERS emit DERIVED family names ("Hinduism",
// "Indo-European") that exist on no row field, so for months they could narrow the dashboard
// but never this grid. The fix is to run the SAME derivation here, from the SAME library
// modules the dashboard's config uses -- one owner per derivation, two consumers:
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
import { canonicalFamilyName } from '@map/colors/language-family.js'
import langFamilyByIso from '@map/data/langFamilyByIso.json'
const props = defineProps({
// ── injected standalone deps (replace Nuxt useRuntimeConfig / useI18n / useLocalePath) ──
profileConfig: { type: String, default: '' },
prayBaseUrl: { type: String, default: '' },
languageCode: { type: String, default: '' },
researchUrl: { type: String, default: '/research/' },
// ★ default '' NOT '/research/': a non-empty prop default is ALWAYS truthy, so the computed
// below could never reach its canonical fallback -- the relative default simply moved houses
// and kept winning (measured: bundle carried the canonical branch AND default:"/research/",
// and every rendered link was still relative). Unset must mean unset. */
researchUrl: { type: String, default: '' },
selectUrl: { type: String, default: '' },
t: { type: Object, default: () => ({}) },
@ -179,6 +191,9 @@ const props = defineProps({
// a REAL row field, unlike the derived language_family/religion_family facets, which is why it
// can move the grid at all.
gospelAccess: { type: String, default: '' },
// ── the two DERIVED family axes (no row field carries them; see DERIVED_AXES below) ──
religionFamily: { type: String, default: '' },
languageFamily: { type: String, default: '' },
// ── presentation knobs copied from UupgsList (defaults preserve today's behaviour) ──
perPage: { type: Number, default: 24 },
@ -194,7 +209,18 @@ const cfg = computed(() => {
})
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
const researchUrl = computed(() => props.researchUrl || cfg.value.researchUrl || '/research/')
// ★ ABSOLUTE CANONICAL DEFAULT (Driver 2026-09-23, pasted from his own browser:
// http://localhost:5174/research/pitaguari/ -- "when I click view more information it does not
// send me to the proper doxa.life page"). The old default '/research/' was ROOT-RELATIVE, so it
// resolved against whatever origin happened to host the element: right on doxa.life, wrong on
// every other host -- the dev server, an iframe embed, a partner site. Same defect class as the
// ../../ child-src bug: a relative default encoding an assumption about the host.
// The shared PeopleGroupDetail already solved this exact problem (PeopleGroupDetail.vue:427):
// window.DOXA_SITE_URL override, else the absolute canonical origin. Adopting the SAME pattern
// here makes the card link and the detail panel agree on where a people group lives -- two
// surfaces, one rule. Prop and cfg still take precedence for hosts that want their own base.
const researchUrl = computed(() => props.researchUrl || cfg.value.researchUrl
|| (((typeof window !== 'undefined' && window.DOXA_SITE_URL) || 'https://doxa.life').replace(/\/+$/, '') + '/research/'))
const selectUrl = computed(() => props.selectUrl || cfg.value.selectUrl || '')
const t = computed(() => ({ ...(cfg.value.t || {}), ...(props.t || {}) }))
@ -254,6 +280,10 @@ const propFilters = computed(() => {
// The API-field-name rule a fourth time, for the same reason: the prop is `gospelAccess` and the
// row field is `imb_gsec`. Keying on the prop name would read undefined on every row.
if (props.gospelAccess) f.imb_gsec = { value: props.gospelAccess, label: lbl(props.gospelAccess) }
// DERIVED axes: the key is not a row field but a DERIVED_AXES accessor name -- rowAxisValue()
// resolves it, so both filter loops stay on the one shared matcher.
if (props.religionFamily) f.__religion_family = { value: props.religionFamily, label: lbl(props.religionFamily) }
if (props.languageFamily) f.__language_family = { value: props.languageFamily, label: lbl(props.languageFamily) }
return f
})
const activeFilters = computed(() => (useFilters.value ? localFilters.value : propFilters.value))
@ -356,6 +386,34 @@ function toggleExact() {
// vocabulary works and a new value needs no code change. Kept deliberately narrow: it only
// widens what counts as equal, so a genuine non-match still filters to nothing.
const norm = (s) => String(s ?? '').toLowerCase().replace(/[\s_&-]+/g, '')
// ── DERIVED AXES: family values computed per-row, byte-parity with dashboard.config.js ──
// religion family = first letter of the religion CODE -> RELIGION_FAMILIES name; language
// family = the API's imb_language_family label when present (1.6% of rows), else the
// ISO-639-3 lookup via langFamilyByIso (lifts coverage to ~96%), both case-folded through
// canonicalFamilyName. Returned as {value,label} so valueMatches() treats a derived axis
// exactly like a fetched one.
const DERIVED_AXES = {
__religion_family: (u) => {
const code = u.religion && typeof u.religion === 'object' ? u.religion.value : u.religion
const c = String(code || '').charAt(0).toUpperCase()
const name = c && RELIGION_FAMILIES[c] ? RELIGION_FAMILIES[c] : ''
return name ? { value: name, label: name } : null
},
__language_family: (u) => {
const f = u.imb_language_family
const api = f && typeof f === 'object' ? (f.label || f.value) : f
let name = ''
if (api) name = canonicalFamilyName(api)
else {
const iso = u.primary_language && typeof u.primary_language === 'object' ? u.primary_language.value : null
const hit = iso && langFamilyByIso[iso]
name = hit ? canonicalFamilyName(hit) : ''
}
return name ? { value: name, label: name } : null
},
}
// ONE resolver for both filter loops: a derived key runs its accessor, a real key reads the row.
const rowAxisValue = (u, field) => (DERIVED_AXES[field] ? DERIVED_AXES[field](u) : u[field])
function valueMatches(vl, selection) {
if (!vl) return false
const want = norm(selection?.value)
@ -367,7 +425,7 @@ function applyDropdownFilters(list) {
let filtered = list
for (const [field, selection] of Object.entries(activeFilters.value)) {
if (field === 'exact') { filtered = filtered } else {
filtered = filtered.filter((u) => valueMatches(u[field], selection))
filtered = filtered.filter((u) => valueMatches(rowAxisValue(u, field), selection))
}
}
return filtered
@ -435,7 +493,7 @@ function applyWith(filters, list) {
let filtered = list
for (const [field, selection] of Object.entries(filters)) {
if (field === 'exact') continue
filtered = filtered.filter((u) => valueMatches(u[field], selection))
filtered = filtered.filter((u) => valueMatches(rowAxisValue(u, field), selection))
}
return filtered
}
@ -569,7 +627,7 @@ async function getUUPGs() {
// primaryLanguage prop match nothing and silently filter the grid to zero rows. Verified live
// (2026-09-22): adding it keeps the response at 2,107 rows and the field is populated on 100%
// of them. Any future filter axis must be added HERE as well as to propFilters.
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion,has_photo,image_url,adopted_by_churches,imb_alternate_name,engagement_status,people_committed,primary_language,imb_reg_of_people_2,imb_reg_of_people_25,imb_gsec&lang=${languageCode.value}`
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion,has_photo,image_url,adopted_by_churches,imb_alternate_name,engagement_status,people_committed,primary_language,imb_reg_of_people_2,imb_reg_of_people_25,imb_gsec,imb_language_family&lang=${languageCode.value}`
loading.value = true
loadFailed.value = false
try {
@ -604,8 +662,14 @@ async function getUUPGs() {
}
// external prop-driven filters should re-filter the grid (when controls are hidden).
watch(() => [props.query, props.wagfRegion, props.wagfBlock, props.rop1, props.religion, props.countryCode,
props.primaryLanguage, props.engagementStatus, props.cluster, props.peopleGroup, props.gospelAccess,
// ★ WATCH THE DERIVED propFilters, NOT a hand-enumerated prop list. This watcher used to spell
// out every filter prop by name -- the THIRD copy of the axis list (defineProps, propFilters,
// here), and the copy nobody remembers: religionFamily/languageFamily landed in the first two
// and the grid received the prop, showed its "Showing" count line, and silently never
// re-filtered (measured: Hinduism selected, first card still a Malaysian Islamic group).
// propFilters already derives from every axis prop, so watching it makes the axis list have
// ONE owner and a future axis cannot miss the watcher.
watch(() => [props.query, propFilters.value,
useSearch.value, useFilters.value], () => {
page.value = 1
filterUUPGs()

View file

@ -90,18 +90,12 @@ export default {
// family totals sum exactly to the leaf-code totals.
religion_family: {
label: 'Religion Family',
// ★ DASHBOARD-ONLY, AND SAYING SO IS THE FIX. This facet narrows the dashboard correctly but
// can never move the grid beneath it, because the value it emits ("Hinduism") is a DERIVED
// family name that exists on no row field — the rows carry religion CODES. Mapping it in
// dashboard-plus-grid's FACET_TO_PROP would compare "Hinduism" against a code and filter the
// grid to ZERO, which is worse than leaving it unmapped.
//
// Measured 2026-09-23 (interop sweep, 4 rows tested): dashboard 2107 -> 693/649/610/83, grid
// unchanged every time. That combination is indistinguishable from a broken button: the row
// highlights, the numbers above move, and the list below ignores you. The honest fix is not to
// wire it — it is to TELL the user this row drives the dashboard only, which is what this flag
// makes the rail render.
dashboardOnly: true,
// ★ NO LONGER dashboard-only (2026-09-23). This facet's value is DERIVED ("Hinduism" from
// the religion code's first letter), which for months meant no grid row field could match
// it -- the honest stopgap was a dashboardOnly flag the rail rendered as "updates this
// dashboard only". The real fix landed instead: people-groups-grid runs the SAME
// derivation (DERIVED_AXES, importing the same RELIGION_FAMILIES map this file reads), so
// the family name the bus carries now byte-matches a value the grid computes per row.
get: (r) => {
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
const c = String(code || '').charAt(0).toUpperCase()
@ -155,11 +149,10 @@ export default {
// byte-match contract intact.
language_family: {
label: 'Language Family',
// ★ DASHBOARD-ONLY — same class as religion_family, different cause. This value is DERIVED
// via langFamilyByIso for ~96% of rows, so it exists on no row field the grid can filter on.
// Measured 2026-09-23 (4 rows tested): dashboard 2107 -> 677/306/222/151, grid unchanged.
// See the religion_family note above for why declaring it beats wiring it.
dashboardOnly: true,
// ★ NO LONGER dashboard-only (2026-09-23) — same arc as religion_family above: the value
// is DERIVED (imb_language_family label, else langFamilyByIso over the ISO code), and the
// grid now runs the SAME derivation from the SAME @map modules (DERIVED_AXES), so the
// Language tree's top tier drives the grid like every fetched axis.
get: (r) => {
const api = lbl(r.imb_language_family)
if (api) return canonicalFamilyName(api)
@ -310,7 +303,38 @@ export default {
// POINT data", which is exactly this map's job, so this is an alignment, not a new choice.
// One line, and it survives the MapLibre swap unchanged.
map: {
mapStyle: 'paper',
// ── PROVIDER: MapLibre ───────────────────────────────────────────────────────────────────
// DRIVER 2026-09-22: "the map on the big picture to be MapLibre"; and 2026-09-23 on the
// terms — "keep every Mapbox component working so we don't break Mapbox compatibility — we
// just want interoperability, so we can come back to Mapbox if this breaks things."
//
// ★ THIS ONE KEY IS THE WHOLE SWAP, AND THAT IS THE POINT. DashboardMapPanel reads it
// (`cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox'`), useMapInstance loads the matching
// engine + CSS, and registry.js resolves the style name on whichever engine is live. Delete
// this line — or set it to 'mapbox' — and the dashboard is byte-for-byte what it was before,
// with the Mapbox token still wired and no other file to restore. Reversibility is not a
// claim here; it is the shape of the change. (Proven by flipping it and rebuilding, not
// asserted — see the verification note in the commit.)
//
// WHY IT COSTS NOTHING TO STAY DUAL: MapLibre ignores accessToken rather than rejecting it,
// so the Mapbox credential stays threaded through untouched while MapLibre is live.
// ★ MAPBOX FOR THIS MAP, BY THE DRIVER'S OWN DESCRIPTION (2026-09-23). Every wording of the
// ask pointed at one thing: "like the Mapbox map", "a mirror or clone of Mapbox's globe space
// style with the stars", "water like Mapbox style". Mapbox Standard's globe NATIVELY renders
// the starfield, the atmosphere and that water -- no clone can beat the original, and we hold
// a working baked token (the Driver's explicit ruling accepted it as public). MapLibre support
// stays fully built and reversible: flip this one key back and the keyless VersaTiles path
// returns. Interoperability was the requirement; it is intact.
provider: 'mapbox',
// 'galaxy' = "deep-space ground, blue oceans" (library/styles/registry.js).
// DRIVER 2026-09-23: "the map style needs to have the BLUE WATER with like the BLACK GALAXY
// background". It resolves on BOTH engines — MapLibre's keyless OpenFreeMap dark and Mapbox
// dark-v11 — each patched to the same blue water at style.load, so flipping `provider` above
// does not change how the map LOOKS, only which engine draws it. That is what makes the
// reversibility test above meaningful rather than cosmetic.
// Was 'paper' (near-white minimal) until this change.
mapStyle: 'galaxy',
// ── HEARTBEAT ZONES ──────────────────────────────────────────────────────────────────────
// A HEARTBEAT ZONE is a country holding the most people groups — where the concentration of
@ -371,7 +395,16 @@ export default {
// ── charts ── (DashboardChartsRail)
charts: [
{ id: 'religion', type: 'donut', facet: 'religion', title: 'Religion', topN: 8 },
// ★ HIGH-LEVEL RELIGIONS ONLY (Driver 2026-09-23: "it should not mention Ethnoreligion and
// Islam multiple times, just once -- just the high-level religions, not broken down into
// smaller categories"). The rollup ALREADY EXISTED as the religion_family facet ("Islam",
// "Hinduism", "Ethnoreligion" -- one row each); the chart was simply pointed at the
// fine-grained `religion` facet ("Islam - Sunni", "Islam - Folk", ...). One word moves it.
// religion_family is dashboardOnly by design (a derived value no row field carries), so a
// slice click narrows the DASHBOARD but not the grid below -- documented behaviour, and for
// a glanceable donut that is the right trade. The full breakdown still lives in the facet
// rail's Religion tree for anyone who needs Sunni vs Folk.
{ id: 'religion', type: 'donut', facet: 'religion_family', title: 'Religion', topN: 8 },
{ id: 'region', type: 'donut', facet: 'wagf_region', title: 'Region', topN: 9 },
{ id: 'country', type: 'bar', facet: 'country_code', title: 'Top countries', topN: 12 },
],

View file

@ -180,11 +180,15 @@ const { startFacetResize, startRailResize, startTableResize, startShellResize, o
// mapH mirrors the page-scroll CSS fallback 40svh (~360px at a 900px viewport — see
// BUG-DASHBOARD-BAND-GROWS-ENORMOUS: 58svh left the default band taller than one viewport;
// 40svh is the value that makes chrome(137px) + map(X) + table(X+32px bar) sum to <=900px).
// shellH default 720 = the map+table combined budget that mapH:360 used to imply on its own
// (360 map + 360 table-with-bar) — see useDashboardLayout.js's DEFAULTS comment for the exact
// accounting. Must mirror --rd-shell-h's CSS fallback below for the same reset()-vs-fresh-load
// reason as the other three.
defaults: { facetW: 296, railW: 264, tableH: '34%', mapH: 360, shellH: 720 },
// ★ NO LOCAL defaults OVERRIDE — deliberately. This line used to pass its own copy of the
// numbers ({ mapH:360, shellH:720 }), which silently OVERRODE the composable's DEFAULTS via the
// { ...DEFAULTS, ...options.defaults } merge. Result, measured 2026-09-23: the composable said
// mapH 260 / shellH 502, this line said 360 / 720, the bundle carried BOTH, and this one won --
// so two height-reclaim passes changed the shell by exactly nothing while every CSS fallback
// moved. The one-quantity-one-owner rule, third costume: a duplicated DEFAULT is still a second
// owner. The composable's DEFAULTS (useDashboardLayout.js:98, with the row arithmetic comment)
// is the single home; anything this host needs to differ must be a conscious, single-key
// override with a reason, never a full mirrored copy that drifts.
})
// KPI strip — generic formatting per kind (mirrors the original inline strip).
@ -223,6 +227,14 @@ useShadowStyles(`
_qa-cycle/01-ideation-research/REFERENCE-slim-material-ui-standards.md, REF-slim-material-ui-standards).
LIGHT ONLY — there is no dark mode in this product and none may be added.
★ SCOPE: this law governs UI CHROME — panels, cards, type, borders, controls; everything
whose colour comes from a token above. It does NOT govern BASEMAP TILES. The map panel
renders a dark basemap ('galaxy' — black ground, blue water, set in dashboard.config.js on
the Driver's 2026-09-23 ruling), and that is DELIBERATE, not drift: a basemap is imagery the
data is drawn ON, chosen for choropleth contrast, not a surface the UI is themed with. A
dark map inside light chrome is the intended composition. Do not "fix" it to match the
palette, and do not cite it as precedent for darkening any chrome.
--dx-scroll-thumb is a DELIBERATE neutral, not a sage: it must read between line and muted
on both white and surface, which a sage tint does not do. It is named, therefore chosen. */
:host{ display:block; width:100%; height:100%; min-height:560px; box-sizing:border-box;
@ -244,7 +256,10 @@ useShadowStyles(`
ONE line, while the charts carry a donut plus a legend that can wrap.
So the widths SWAP ROLES: the tree takes the wider column, the charts the narrower.
The pair still sums to 560px, so the centre map cell is unchanged. */
--facet-w:296px; --rail-w:264px;
/* ★ SYMMETRIC SIDEBARS (Driver 2026-09-23: "the charts sidebar can be wider so it is the
same width as the left sidebar, which will put the map in the exact center"). rail
264 -> 296 = facet width; equal flanks centre the middle column by construction. */
--facet-w:296px; --rail-w:296px;
/* ── ★ ONE EDGE GUTTER FOR THE WHOLE HEADER STACK ────────────────────────────────────
(card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK: "on the edges there should be padding
so that everything lines up perfectly.")
@ -272,14 +287,14 @@ useShadowStyles(`
failure mode --rd-edge was introduced to prevent for the horizontal gutter. */
/* ★ HEIGHT RECLAIM 2026-09-23: topbar 52->44, stats 30->26. Both are now VARS so a host can
tune them without editing three grid-template-rows declarations that must agree. */
--rd-topbar-h:44px;
--rd-stats-h:26px;
--rd-topbar-h:38px;
--rd-stats-h:22px;
--fs-xs:10px; --fs-sm:11px; --fs-md:12px; --fs-lg:13px; --fs-xl:16px;
--r-lg:10px; --r-md:6px; --r-pill:999px; }
.profile-loader{ width:100%; height:100%; }
.rd-shell{ position:relative; display:grid; width:100%; height:100%; min-height:560px; box-sizing:border-box;
grid-template-columns: var(--facet-w,296px) minmax(0,1fr) var(--rail-w,264px);
grid-template-rows: var(--rd-topbar-h,44px) auto var(--rd-stats-h,26px) 1fr var(--table-h,34%);
grid-template-columns: var(--facet-w,296px) minmax(0,1fr) var(--rail-w,296px);
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) 1fr var(--table-h,34%);
grid-template-areas: "topbar topbar topbar" "filters filters filters" "stats stats stats" "facets map rail" "facets table rail";
gap:1px; background:var(--rd-line, #d1d4cd); container-type:inline-size;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
@ -297,11 +312,11 @@ useShadowStyles(`
still showing the live matched count and the Export CSV button (card
BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS). The row shrinks to the bar's height
instead of 0, so the map still takes back nearly the whole cell via its 1fr. */
.rd-shell.rd-table-collapsed{ grid-template-rows:var(--rd-topbar-h,44px) auto var(--rd-stats-h,26px) 1fr var(--table-bar-h,30px); }
.rd-shell.rd-table-collapsed{ grid-template-rows:var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) 1fr var(--table-bar-h,30px); }
/* Facet/rail collapse — override the COLUMN template (not the --*-w vars) so collapse wins
over a dragged inline width, exactly like the table row-template above. Map is minmax(0,1fr)
and self-resizes via DashboardMapPanel's ResizeObserver → map.resize(). */
.rd-shell.rd-facet-collapsed{ grid-template-columns:0 minmax(0,1fr) var(--rail-w,264px); }
.rd-shell.rd-facet-collapsed{ grid-template-columns:0 minmax(0,1fr) var(--rail-w,296px); }
.rd-shell.rd-rail-collapsed{ grid-template-columns:var(--facet-w,296px) minmax(0,1fr) 0; }
.rd-shell.rd-facet-collapsed.rd-rail-collapsed{ grid-template-columns:0 minmax(0,1fr) 0; }
/* Always-reachable edge carets pinned to the map, toggle each rail (desktop only). */
@ -388,8 +403,60 @@ useShadowStyles(`
shell budget). Every derived measurement below (table budget, facets/rail
span) now roots in --rd-shell-h so the total stays the single source of
truth and the two handles cannot fight over the same pixels. */
.rd-shell.rd-page-scroll{ height:auto; --table-h:auto; }
.rd-shell.rd-page-scroll > [data-region="map"]{ height:var(--rd-map-h, 40svh); overflow:hidden; }
/* ★ THE SHELL TOTAL NOW DERIVES FROM THE TABLE, NOT THE OTHER WAY ROUND (regression I introduced
in a693865 and the Driver caught immediately: "the height of the table is smaller now, but that
introduced a GAP, a grey spot ... which also broke the ability to resize the table").
WHAT I BROKE. The design was one budget with two consumers: --rd-shell-h was the total, and the
table's keyhole was DERIVED as shell - map - bar - foot. Capping the keyhole at 7 rows replaced
that derivation with an independent constant, so the table stopped answering to the shell. The
shell kept asserting its old ~720px total while the table gave back 118px, and since nothing
else consumed the slack it showed up as dead space — the grey spot. The resize handle broke for
the same reason: it writes --rd-shell-h, which the table no longer reads, so dragging moved a
number that nothing downstream obeyed.
THE FIX IS TO RESTORE ONE SOURCE OF TRUTH, but pointing the other way: the TABLE's seven rows
are now the declared quantity and --rd-shell-h is COMPUTED from it (map + bar + rows + foot).
Both handles keep working because both still write inputs to that sum: the map/table divider
writes --rd-map-h, and the shell handle writes --rd-shell-h, which overrides this default.
★ The var indirection matters: an explicit --rd-shell-h from the drag still wins, so this is a
DEFAULT, not a lock. That is what keeps the resize alive rather than pinning the element. */
/* ★ THE MAP ROW MUST NOT BE 1fr IN PAGE-SCROLL — this is the grey gap, found by measurement.
The base grid (line 290) sets the map row to 1fr, which is right in FIXED-VIEWPORT: the shell
has a real bounded height and 1fr means "take what is left". In PAGE-SCROLL the shell is
height:auto, so there is no fixed total to divide — 1fr instead becomes "stretch to whatever
the content implies", and the map ELEMENT is separately pinned to --rd-map-h. The row grows,
the element does not, and the difference paints as dead space between the map and the table.
MEASURED in a fresh context: shell 849 while its parts sum to 602 — a 117px gap, exactly the
"grey spot between the height of the map" the Driver reported twice.
Pinning the row to the SAME var the element uses makes row and element one quantity again, so
the slack cannot exist. Both drags keep working because both write that var (or --rd-shell-h,
which the table's keyhole below derives from). */
/* ★ THE GREY GAP BETWEEN MAP AND TABLE = THE 1fr ROW STRETCHING PAST ITS PINNED OCCUPANT.
The base grid gives the map row 1fr, which is correct in fixed-viewport (a real bounded total
to divide) and wrong in page-scroll, where the shell is height:auto: 1fr then stretches to
whatever the content implies while the map ELEMENT stays pinned at --rd-map-h. The row grows,
the element does not, and the difference paints as dead space above the table — reported by
the Driver three separate times. Pinning the ROW to the same var the ELEMENT uses makes them
one quantity, so the slack cannot exist; the table row goes auto for the same reason.
★ HISTORY, so this does not get reverted on a false signal again: this exact rule was applied
earlier today and reverted when a probe reported "no shell" — the real cause was a DEAD DEV
SERVER, not this CSS. Verified this time against a live server with a screenshot. */
.rd-shell.rd-page-scroll{ height:auto; --table-h:auto;
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) var(--rd-map-h,300px) auto; }
/* ★ COLLAPSE GIVES ITS SPACE TO THE MAP (Driver 2026-09-23: "collapsing the table is supposed to
collapse down and increase the size of the map -- instead it collapsed up and introduced white
space under the collapsed table"). The base .rd-table-collapsed template still says 1fr for the
map row, and 1fr cannot grow in an auto-height shell -- there is no fixed total to divide -- so
the freed space pooled under the bar as white nothing. In page-scroll the collapsed map takes
the SAME shell budget the open state uses, minus only the bar: the one-owner derivation again,
so collapse/expand and both drag handles all move the same quantity instead of fighting.
Both the ROW and the ELEMENT get the height, for the same reason they share --rd-map-h when
open: two owners of one height is the bug this file keeps re-learning. */
.rd-shell.rd-page-scroll.rd-table-collapsed{
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px)
calc(var(--rd-shell-h, 542px) - var(--table-bar-h,30px)) var(--table-bar-h,30px); }
.rd-shell.rd-page-scroll.rd-table-collapsed > [data-region="map"]{
height:calc(var(--rd-shell-h, 542px) - var(--table-bar-h,30px)); }
.rd-shell.rd-page-scroll > [data-region="map"]{ height:var(--rd-map-h, 300px); overflow:hidden; }
.rd-shell.rd-page-scroll > [data-region="table"]{ overflow:visible; }
.rd-shell.rd-page-scroll .rd-table-wrap{ height:auto; }
/* ★ THE TABLE KEEPS AN INTERNAL SCROLL, BUT A GENEROUS ONE — and that is the whole fix.
@ -456,7 +523,7 @@ useShadowStyles(`
GOAL CONTEXT: "we are trying to RECLAIM HEIGHT." 7 rows is 182px against today's 300px keyhole,
so this alone returns 118px of the ~147px available. */
.rd-shell.rd-page-scroll .rd-grid{
max-height:var(--rd-table-max-h, calc(var(--rd-table-rows, 7) * var(--rd-table-row-h, 26px))); }
max-height:var(--rd-table-max-h, calc(var(--rd-shell-h, 542px) - var(--rd-map-h, 300px) - var(--rd-table-bar-h, 32px) - var(--rd-table-foot-h, 28px))); }
/* ── FACETS/RAIL COLUMNS BOUNDED IN PAGE-SCROLL MODE (BUG-DASHBOARD-BAND-GROWS-ENORMOUS) ──────
THE ROOT CAUSE, one level up from the table rule above. .rd-facets/.rd-rail are authored
height:100%, which is correct in the FIXED-VIEWPORT grid (rows sum to a real, bounded
@ -533,7 +600,14 @@ useShadowStyles(`
NUMBER the cap computes was wrong, not the need for a cap at all. */
.rd-shell.rd-page-scroll > [data-region="facets"],
.rd-shell.rd-page-scroll > [data-region="rail"]{ min-height:0; overflow:hidden;
max-height:calc(var(--rd-shell-h, 80svh) + 1px); }
/* ★ FALLBACK UNIFIED AT 502px (was 80svh = 720px). Two rules read --rd-shell-h and their
FALLBACKS disagreed: the table keyhole fell back to 502px (seven rows) while this cap fell
back to 720px. Nothing sets the var at rest, so the sidebars believed the shell was 119px
taller than the table did -- the spanning columns stretched the auto table row to fit, and
the surplus painted as the grey band the Driver reported between/under map and table, and
made a divider drag look broken (the row would not follow, the slack just moved).
ONE QUANTITY, ONE OWNER applies to fallbacks too: same var, same fallback, everywhere. */
max-height:calc(var(--rd-shell-h, 542px) + 1px); }
/* ★ THE ELEMENT MUST SIZE TO ITS CONTENT IN PAGE-SCROLL (Driver 2026-09-23: "massive grey gap
between map and table and the map overflows into the people-group grid page — the dashboard
@ -561,7 +635,7 @@ useShadowStyles(`
UNPROVEN because no host has yet been measured before-and-after through this path. */
:host([data-page-scroll="true"]){ height:auto; min-height:0; }
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:var(--rd-topbar-h,44px) auto var(--rd-stats-h,26px) minmax(0,1fr) 46px;
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) minmax(0,1fr) 46px;
grid-template-areas:"topbar" "filters" "stats" "body" "seg"; }
.rd-shell.rd-narrow > [data-region]{ grid-area:body; min-width:0; min-height:0; }
.rd-shell.rd-narrow > [data-region="topbar"]{ grid-area:topbar; }

View file

@ -190,7 +190,7 @@ useShadowStyles(`
restore white-space:nowrap + ellipsis here, and do not re-cap this to the donut's height:
that cap is precisely what forced the unreadable ellipsis the Driver rejected. */
.rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:var(--dx-text, #3b463d); max-height:120px;
overflow-y:auto; overflow-x:auto; flex:1 1 auto; min-width:0;
overflow-y:auto; overflow-x:hidden; flex:1 1 auto; min-width:0;
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
.rd-legend::-webkit-scrollbar{ width:6px; height:6px; }
.rd-legend::-webkit-scrollbar-track{ background:transparent; }
@ -200,11 +200,46 @@ useShadowStyles(`
now two lines tall, and centring would float the swatch and the count in the middle of that
block, detached from the first line of text they label. The count is top-aligned with a 1px
nudge (.rd-li b) so it reads as belonging to the row's first line. */
.rd-li{ display:flex; align-items:flex-start; gap:5px; cursor:pointer; padding:1px 2px; border-radius:3px; }
/* ★ THE CONVENTIONAL LEGEND ROW, on the Driver's final ruling (2026-09-23): "I am not
impressed with that design -- use conventional designs only", after rejecting both the
scroll-to-read variant ("I cannot scroll right") and the frozen column's painted backing
("super white background, it looks odd; it was to be invisible"). This is the pattern every
chart library ships: swatch, label that ELLIPSIZES at the available width, count right-
aligned in a fixed-width slot. No horizontal scroll, no sticky, no background -- the number
needs no backing because nothing can ever slide beneath it. The full label lives in the
row's existing :title tooltip. Rows stretch to the container, so every count shares one
right edge by ordinary flex geometry. */
.rd-li{ display:flex; align-items:center; gap:5px; cursor:pointer; padding:1px 2px; border-radius:3px; }
.rd-li > i{ flex:0 0 auto; }
.rd-li .rd-lt{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } /* max-content: the row is as wide as its label, so the scrollport has something to scroll */
.rd-li:hover{ background:var(--dx-surface, #f3f3f1); } .rd-li.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
/* margin-top nudges keep the swatch and the count optically on the label's FIRST line now that
a row can be two lines tall. */
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; margin-top:3px; } .rd-li b{ margin-left:auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; flex:0 0 auto; padding-left:4px; margin-top:1px; }
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; margin-top:3px; }
/* ★ THE COUNT IS A FROZEN COLUMN (Driver 2026-09-23): "the number column was supposed to be
FROZEN with the z-index topped with a WHITE BACKGROUND so that the text doesn't underlap it, so
that whenever you scroll to the right you can read the text but the numbers stay visible."
This is the other half of the horizontal-scroll change and shipping without it was incomplete:
nowrap + overflow-x:auto made the long label reachable, but the label then slides UNDER the
count and the two overlap mid-scroll. A scrollable row needs a pinned column, or the thing you
scrolled to read collides with the thing you were reading it against.
position:sticky right:0 pins it to the SCROLLPORT's right edge, so it holds while the label
travels beneath it. The opaque background is load-bearing, not decoration — without it the
label shows THROUGH the number and both become unreadable, which is worse than clipping.
Background is --rd-panel (the card's own surface) so the pin matches its host rather than
assuming white; the card is #fff today but a host that re-skins the panel should not get a
white rectangle floating on a tinted card. */
/* ★ THE COUNT COLUMN IS UNIFORM, RIGHT-JUSTIFIED AND IMMOVABLE (Driver 2026-09-23: "set the
number column to be justified right, same width, uniform, immovable... so it is easier to
use -- and the same goes for the region chart"). One fixed width for every row means the
digits align into a scannable column instead of each number sitting wherever its label ended;
right-justified plus the existing tabular-nums makes magnitudes comparable at a glance (541
visibly longer than 72). 3.4em clears the widest live value (1,309 in the region chart) with
a little air. sticky right:0 (from the frozen-column fix) is what makes it immovable while the
label scrolls beneath. Both donut legends share .rd-li, so religion and region get this from
the one rule -- which is exactly why it lives here and not per-chart. */
.rd-li b{ flex:0 0 auto; min-width:3.4em; text-align:right;
color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; }
/* The label is the ONLY item allowed to shrink. It used to be a bare text node, which in a
nowrap flex row cannot shrink at all: the row grew past .rd-legend (overflow-x:hidden) and
carried the VALUE out of sight — "Ethnoreligion - Chinese Folk Religion" rendered with no

View file

@ -469,7 +469,42 @@ function toggleDock() { collapsed.value = !collapsed.value; bus && bus.emit('tog
* actually delivered to a host page.
*/
const isFull = ref(false)
const wrapEl = ref(null)
// ★ NATIVE FULLSCREEN, NOT A FIXED OVERLAY (Driver 2026-09-23: "there is no way to exit full
// screen or export csv when you enter full screen"). The bar with BOTH buttons is inside this
// wrap, so structurally they should have been visible -- but position:fixed inside a shadow root
// resolves against any transformed/contained ANCESTOR, not the viewport, and in the cap-5 shell
// that pinned the "fullscreen" wrap to a pane whose top sat above the visible area: the bar (and
// its Exit + Export CSV) rendered off-screen. The leaf host has no such ancestor, which is why
// every leaf measurement passed while the shell was broken -- the two-host rule again.
// element.requestFullscreen() is the wheel the platform ships: the browser guarantees viewport
// coverage regardless of ancestor transforms, supplies ESC natively, and fires fullscreenchange
// so our button state can follow EVERY exit path (ESC, F11, another element taking fullscreen)
// instead of only our own toggle. The .is-full class stays as the styling hook and as the
// fallback when the API is unavailable (iframes without allowfullscreen).
function _syncFull() {
// ★ SHADOW BOUNDARY: document.fullscreenElement retargets to the outermost shadow HOST, and
// host.contains(innerNode) is false across the boundary -- so the naive check reported "not
// fullscreen" while the browser was visibly fullscreen (measured: fullscreenElement had the
// custom element's empty className, and the button state never flipped). The real element is on
// the wrap's OWN root: getRootNode().fullscreenElement pierces exactly one level, which is where
// this wrap lives.
const w = wrapEl.value
const root = w && w.getRootNode ? w.getRootNode() : document
const fsEl = (root && root.fullscreenElement) || document.fullscreenElement
isFull.value = !!(fsEl && w && (fsEl === w || (fsEl.contains && fsEl.contains(w))))
}
if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', _syncFull)
onBeforeUnmount(() => { if (typeof document !== 'undefined') document.removeEventListener('fullscreenchange', _syncFull) })
function toggleFull() {
const el = wrapEl.value
if (el && el.requestFullscreen) {
if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}) }
else { el.requestFullscreen().then(_syncFull).catch(() => { isFull.value = true }) } // fallback: the old overlay
// isFull follows via fullscreenchange; the collapsed-expand below still applies either way
if (!isFull.value && collapsed.value) { collapsed.value = false; bus && bus.emit('toggleTable', false) }
return
}
isFull.value = !isFull.value
// Entering while collapsed would show a fullscreen HEADER BAR and nothing else. The user
// asked for the table, so expand it — and tell the shell, which owns the grid row, or the
@ -480,7 +515,14 @@ function toggleFull() {
}
// Esc is the fullscreen contract every user already knows; without it a pseudo-fullscreen
// overlay is a trap, since there is no browser-supplied exit the way the native API gives one.
function onKeydown(e) { if (e.key === 'Escape' && isFull.value) { isFull.value = false } }
function onKeydown(e) {
if (e.key !== 'Escape') return
// Native fullscreen: delegate to the API so EVERY consumer of fullscreenchange stays in sync.
// (A real user's ESC is handled by the browser itself; this path serves tests and the overlay
// fallback, where no browser exit exists and a trapped user was the original defect.)
if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}) ; return }
if (isFull.value) isFull.value = false
}
// ── footer aggregation ──
const sumPop = computed(() => filtered.value.reduce((s, r) => s + (num(r.population) || 0), 0))
@ -560,6 +602,24 @@ useShadowStyles(`
pointer-events:none; color:var(--dx-muted, #6d766e); font-size:12px; }
/* Hidden where the clusters would collide with it — at narrow widths there is no free middle. */
@media (max-width: 1100px){ .rd-bar-hint{ display:none; } }
/* ★ THE BAR IS A SELECTION TOOLBAR WHILE A SELECTION EXISTS (the 1280px flood, measured: with
one row selected the bar wrapped to 43px and pushed a control out of it -- the Driver's
"flooded with buttons that overflow"). The centred hint and the selection cluster cannot both
fit a 666px middle column, and the hint is the one carrying redundant information there: its
count already lives in the selection pill. It yields exactly while a selection exists and
returns the moment the selection clears -- state-scoped, so the resting bar is unchanged.
(The 1100px media rule above is the same idea keyed on width; this one is keyed on state.) */
.rd-table-wrap:not([data-sel-count="0"]) .rd-bar-hint{ display:none; }
/* ★ MEASURED AT 1280 (the Driver's flood, second cause): hiding the hint was not enough -- the
in-flow title and count are separate children, and squeezed to 48px the title wrapped
INTERNALLY ("People"/"Groups" on two lines), growing the bar to 43px and tumbling the spacer.
While a selection exists they say nothing the bar does not already say -- the pill carries the
selection count and the composite's own section header above the table already reads "People
Groups" -- so they yield with the hint and return on clear. After: every child on one 32px
line at 1280, zero overflow. */
.rd-table-wrap:not([data-sel-count="0"]) .ttl,
.rd-table-wrap:not([data-sel-count="0"]) .ct{ display:none; }
.ttl{ white-space:nowrap; } /* and the title may never two-line itself, selection or not */
/* Export CSV — lives on THIS bar now (moved from the dashboard topbar). Material-light DX palette:
primary #92b195 surface, deep ink label, border #d1d4cd. Stays visible while collapsed. */
.rd-table-bar .rd-csv{ display:inline-flex; align-items:center; gap:6px; flex:0 0 auto; cursor:pointer; user-select:none;
@ -675,6 +735,8 @@ useShadowStyles(`
position:fixed takes the wrapper out of the shell's grid row without moving it in the TREE,
so every inherited token and every ancestor rule still applies and we override only what we
mean to. z-index sits just under the max a browser honours, above the map's own controls. */
.rd-table-wrap:fullscreen{ background:var(--dx-elevated,#fff); overflow:auto; }
.rd-table-wrap:fullscreen .rd-grid{ max-height:none !important; }
.rd-table-wrap.is-full{ position:fixed; top:0; left:0; right:0; bottom:0; z-index:2147483646;
height:100dvh; width:100vw; box-shadow:0 0 0 100vmax rgba(59,70,61,.22); }
/* ★ THE OVERRIDE THAT ACTUALLY DOES THE WORK. The shell caps this element at seven rows via
@ -708,7 +770,7 @@ useShadowStyles(`
</script>
<template>
<div class="rd-table-wrap" :class="{ 'is-full': isFull }" :data-sel-count="selCount"
<div ref="wrapEl" class="rd-table-wrap" :class="{ 'is-full': isFull }" :data-sel-count="selCount"
:data-fullscreen="isFull ? '1' : '0'">
<!-- Header bar — ALWAYS rendered, collapsed or not. The count + Export CSV are the two
controls the Driver asked to keep docked under the map when the grid is hidden. -->
@ -718,9 +780,18 @@ useShadowStyles(`
<span class="ttl">People Groups</span>
<span class="ct" data-table-count>{{ fmt(rowCount) }} {{ rowCount === 1 ? 'group' : 'groups' }}</span>
<!-- role=status so assistive tech announces the count as the selection changes. -->
<!-- ★ NO CLEAR BUTTON IN THIS BAR (Driver 2026-09-23, on the selection flood: "it does not
need to give me a Clear Selected button in the header because the FOOTER of the page
already gives me that"). One action, one home; this pill is a status readout only.
★ VERIFIED WHAT ACTUALLY CLEARS, rather than repeating the premise: no literal footer
clear exists in THIS component -- the surviving clear paths are the thead master
checkbox (un-checking it deselects all rows) and the detail drawer's close
(DashboardDetailDrawer.vue:104 calls bus.clearSelection()). Both remain, so removing
the header x removes a DUPLICATE, not the capability. clearSel() stays defined for the
bus and any future caller. This also deletes the one control in the cluster that read
as "a button that doesn't make sense" -- a bare x with no label. -->
<span v-if="selCount" class="rd-pill" data-sel-pill role="status">
<span>{{ fmt(selCount) }} selected</span>
<button title="Clear selection" aria-label="Clear selection" @click="clearSel">✕</button>
</span>
<!-- Selection VIEWS — separated from the pill because they mutate the VIEW, not the
selection. Only meaningful once something is selected, so they appear with it. -->

View file

@ -480,7 +480,15 @@ useShadowStyles(`
.rd-facets .fr-body::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
.rd-facets .fr-body::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
.fr-sec{ border-bottom:1px solid var(--rd-line, #d1d4cd); }
.fr-sech{ display:flex; align-items:center; gap:6px; padding:5px 12px; cursor:pointer; user-select:none;
/* ★ STICKY SECTION HEADERS, GITHUB-MARKDOWN STYLE (Driver 2026-09-23: "when I am scrolling
down it needs to keep the section headers visible until you scroll into the next section --
like markdown files"). The conventional mechanism does all of it natively: position:sticky
top:0 inside the scrolling .fr-body pins the current header, and the NEXT header pushes the
previous one away as it arrives, because two sticky siblings at the same top contend for the
slot. The opaque background is required -- rows scroll UNDER the pinned header, and sticky
over transparent paints the rows through the label. z-index keeps it above row hover states. */
.fr-sech{ position:sticky; top:0; z-index:2; background:var(--rd-panel,#fff);
display:flex; align-items:center; gap:6px; padding:5px 12px; cursor:pointer; user-select:none;
font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted, #6d766e); }
.fr-sech:hover{ background:var(--rd-bg, #f3f3f1); }
.fr-sech .chev{ margin-left:auto; transition:transform .15s; font-size:10px; color:var(--dx-muted, #6d766e); }
@ -680,11 +688,15 @@ useShadowStyles(`
<template>
<aside class="rd-facets" data-region="facets">
<div class="fr-head">
<!-- "Tree Legend" — the Driver's naming ruling: the word "semantic" is dropped from the
<!-- "Vertical Legend" — the Driver's naming ruling (2026-09-23): "it shouldn't say tree
legend... rename the component to be called Vertical... use the term nesting or
folders". The visible title says VERTICAL; the drill-down vocabulary in code and
comments is NESTING/FOLDERS, not tree/semantic. Earlier ruling, kept: -->
<!-- (prior note) the word "semantic" is dropped from the
user-facing label (the component/docs still describe the semantic-tree MODEL).
The four stacked trees below it are People · Place · Language · Religion, in that
order — the four axes a researcher navigates to find who has gospel resources. -->
<div class="fr-title">Tree Legend</div>
<div class="fr-title">Vertical Legend</div>
<div class="fr-scope" data-scope-count><b>{{ scopeCount.toLocaleString('en-US') }}</b> groups in scope</div>
</div>
<div class="fr-body" ref="railEl" @click="onRowClick" @keydown="onRowKey">

View file

@ -29,11 +29,17 @@ import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDa
// dot a prayer filter leaves on the map can never disagree with the level the filter selected —
// one threshold, one owner. (The dashboard facet already reuses getPrayerLevel for the same reason.)
import { applyColor as prayerColorExpr } from '@map/colors/prayer-progress.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
const dash = inject('dashboardData', null)
const bus = inject('selectionBus', null)
const mapboxToken = inject('mapboxToken', ref(''))
const cfg = inject('dashboardConfig', {})
// The host's parsed profile-config, used ONLY to resolve the assets base for the keyless country
// boundaries on the MapLibre path (see addLayers). Same channel every other plugin already reads;
// resolveAssetsBase degrades correctly when it is null, so a host that provides nothing still
// resolves the tree from this bundle's own script URL.
const profileConfig = inject('profileConfig', ref(null))
const filterSet = bus?.filterSet || {}
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
@ -86,11 +92,30 @@ const mapContainer = ref(null)
// ★ THE TOKEN IS NOT DROPPED WHEN provider='maplibre'. accessToken stays wired: MapLibre ignores
// it, and keeping it means flipping the prop back to 'mapbox' is a one-word change with nothing
// else to restore. That is what "hot-swappable" has to mean to be worth anything.
const mapProvider = computed(() => (cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox'))
// ★ READ BOTH LEVELS, for the same reason `style` below does. This originally read ONLY
// `cfg.provider`, while every other map setting (mapStyle, bins, geoFacet, colorRamp) lives under
// `cfg.map`. A host that put `provider` where the rest of its map config already sits got NO
// error and NO swap — the map silently stayed on Mapbox and the config looked correct. That is
// the exact failure the note on `style` below names: a setting silently ignored because it sat
// one level off is worse than no setting. `cfg.map.provider` is the spelling to prefer; the
// top-level one is kept working so nothing already using it breaks.
const mapProvider = computed(() => {
const p = cfg.map?.provider || cfg.provider
return p === 'maplibre' ? 'maplibre' : 'mapbox'
})
// Derived once so the source/layer branches in addLayers read the SAME decision the engine was
// loaded with — a second inline `cfg.provider === …` test is how the two drift apart.
const isMaplibreProvider = computed(() => mapProvider.value === 'maplibre')
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer, accessToken: unref(mapboxToken) || (mapboxToken && mapboxToken.value) || '',
center: [28, 18], zoom: 1.45, minZoom: 1,
// ★ ZOOMED OUT BY DEFAULT, WITH REAL ZOOM-OUT RANGE (Driver 2026-09-23: "I don't want the map
// zoomed in by default but zoomed out... the edges of the map need to be truly around the edges
// so you have more zoom control and zoom function distance"). zoom 1.45 -> 1.1 opens on the
// whole sphere; minZoom 1 -> 0.5 plus skipWorldFloor gives the zoom-out gesture somewhere to
// go. The floor being skipped is the mercator "no duplicate world" rule, which is meaningless
// on a globe -- a sphere HAS no second world to show. Flat mercator keeps the floor.
center: [28, 18], zoom: 1.1, minZoom: 0.5, skipWorldFloor: true,
provider: mapProvider.value,
// A named style resolves per-provider via the registry; leaving it unset keeps each engine's own
// default, which is what shipped before this change.
@ -464,7 +489,14 @@ function setLiveResize(on) {
const BIN_COLORS = (() => {
const RAMP_PRESETS = {
'heartbeat-red': ['#f4cec4', '#ecaea1', '#e08678', '#ce544b', '#b10010'],
greyscale: ['#e8e8e8', '#a9a9a9', '#6f6f6f', '#3a3a3a', '#060606'],
// ★ BIN 1 DARKENED #e8e8e8 -> #c9c9c9 (Driver 2026-09-23: "bangladesh is not showing up as a
// polygon"). It WAS rendering -- queryRenderedFeatures returned the BGD polygon painted
// #e8e8e8 -- but against the #f3f3f1 no-data surface that is a 1.06 contrast ratio at 0.78
// opacity: a 1-4-group country was pixel-identical to an empty one. The ramp's first step
// must be separated from NO-DATA at least as clearly as from bin 2, or the lowest bin
// silently reads as "not in the data" (ColorBrewer sequential ramps assume no competing
// no-data fill on the same canvas; when there is one, start the ramp a step darker).
greyscale: ['#c9c9c9', '#a9a9a9', '#6f6f6f', '#3a3a3a', '#060606'],
}
return RAMP_PRESETS[cfg.map?.colorRamp] || RAMP_PRESETS['heartbeat-red']
})()
@ -749,9 +781,54 @@ function refreshSelected() {
function addLayers() {
const m = map.value; if (!m || added) return
added = true
if (!m.getSource('rd-countries')) m.addSource('rd-countries', { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
// ── COUNTRY POLYGONS: the choropleth's source, PER PROVIDER ─────────────────────────────────
// ★ THIS IS WHY THE MapLibre SWAP IS NOT ONE CONFIG LINE. The choropleth read from
// `mapbox://mapbox.country-boundaries-v1`, a Mapbox-hosted VECTOR TILESET. MapLibre cannot
// fetch a mapbox:// URL at all — it throws
// Fetch API cannot load mapbox://mapbox.country-boundaries-v1. URL scheme "mapbox" is not supported
// and then renders the basemap perfectly with NO choropleth on it. MEASURED, not predicted:
// the first MapLibre screenshot showed a correct black-and-blue galaxy map on which every
// landmass was uniform black while the legend still advertised five populated bins. It looked
// like a deliberate dark style. The primary data layer was simply missing.
//
// So the swap needs a KEYLESS equivalent, and the tree already ships one:
// assets/boundaries/world-adm0.geojson (162 ADM0 features), already staged by generate-tree
// and already consumed by three other plugins (upg-100-list, connectivity-map,
// resources-map) through the same resolveAssetsBase helper. No new asset, no new dependency.
//
// ★ THE KEY BRIDGE, AND WHY IT IS DONE HERE RATHER THAN IN THE EXPRESSIONS. The tileset names
// the ISO field `iso_3166_1_alpha_3`; the geojson names it `iso3`. Six separate places read
// that field — buildPaint's fill-color AND fill-opacity match expressions, the click handler,
// the hover handler, and the queryRenderedFeatures path. Branching each of them would be six
// chances to miss one, and a missed one fails silently (a `match` on an absent property just
// falls through to its default, i.e. renders as no-data). Instead the property is normalized
// ONCE, as the data is loaded, so every downstream expression keeps reading the single name it
// always read and the Mapbox path stays byte-for-byte unchanged.
if (!m.getSource('rd-countries')) {
if (isMaplibreProvider.value) {
// Add EMPTY first, then setData after the fetch: addLayer below must run synchronously to
// keep this layer underneath the pins (layer order is insertion order). An awaited fetch
// here would put the choropleth on TOP of every bubble.
m.addSource('rd-countries', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0.geojson`)
.then((r) => r.json())
.then((gj) => {
for (const f of gj.features || []) {
if (f.properties && f.properties.iso3) f.properties.iso_3166_1_alpha_3 = f.properties.iso3
}
const src = map.value && map.value.getSource('rd-countries')
if (src) src.setData(gj)
})
.catch((e) => console.warn('[rdash] country boundaries failed to load', e))
} else {
m.addSource('rd-countries', { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
}
}
const { fillColor, fillOpacity } = buildPaint()
m.addLayer({ id: 'rd-choropleth', type: 'fill', source: 'rd-countries', 'source-layer': 'country_boundaries',
m.addLayer({ id: 'rd-choropleth', type: 'fill', source: 'rd-countries',
// 'source-layer' applies to VECTOR TILE sources only. On a geojson source MapLibre rejects
// the layer outright ("does not need a source-layer"), so it is set for Mapbox alone.
...(isMaplibreProvider.value ? {} : { 'source-layer': 'country_boundaries' }),
// COUNTRY BORDERS. The outline was '#ffffff', which was legible only because the fill
// underneath ran to black. On a pale ramp a white border measures 1.11–1.84 — it would
// vanish and the map would read as one undifferentiated wash. The Material Light MUTED
@ -959,6 +1036,23 @@ function addLayers() {
}
} catch (_) { /* verify hook */ }
applyProjection()
// ★ THE GALAXY, THE WAY MAPBOX DOES IT (Driver: "find out how Mapbox does the galaxy stars and
// see if we can use that"). Mapbox GL renders space and the starfield ENGINE-SIDE via the fog
// API -- documented at docs.mapbox.com/mapbox-gl-js/guides/globe/ ("stars and atmosphere are
// customized with the fog property"). These are their documented example values with one
// change: space-color is pure black on the Driver's instruction ("not grey"). star-intensity
// 0..1 is the engine's own star layer -- no canvas, no custom code, the actual Mapbox stars.
// Guarded: MapLibre has no setFog (its equivalent is the sky spec), so on that provider this is
// a silent no-op and the plain black container behind the transparent sphere carries the void.
try {
if (typeof m.setFog === 'function') m.setFog({
'color': 'rgb(186, 210, 235)', // lower-atmosphere haze at the horizon
'high-color': 'rgb(36, 92, 223)', // upper atmosphere
'horizon-blend': 0.02,
'space-color': '#000000', // the void — black, not grey
'star-intensity': 0.6,
})
} catch (_) { /* an engine without fog keeps the container background instead */ }
// selection pen: rebuild the highlight source whenever the selectionSet changes (toggle on/off).
if (bus) bus.on('select', refreshSelected)
refreshSelected()
@ -1246,7 +1340,21 @@ function syncAttribClearance() {
// ── RDASH-ENHANCE: GLOBE (default) <-> MERCATOR projection toggle ──
const projection = ref('globe')
function applyProjection() { const m = map.value; if (m) { try { m.setProjection(projection.value) } catch (_) {} } }
// ★ THE TWO ENGINES DISAGREE ON THIS ONE CALL, AND THE CATCH HID IT (Driver 2026-09-23: "the map
// style is not a globe"). Mapbox GL takes setProjection('globe') -- a bare string. MapLibre v5 takes
// setProjection({ type: 'globe' }) -- an object. Passing a string to MapLibre throws, the empty
// catch below swallowed it, and the map silently fell back to flat mercator with NO console error.
// That is why this appeared the moment the dashboard moved to provider:'maplibre': the default was
// still 'globe' and the request simply stopped arriving.
// Try the object form first (MapLibre, and harmless to attempt), then the string (Mapbox). The catch
// stays because a provider without ANY projection support must degrade rather than break the panel --
// but it now catches a genuine unsupported-engine case instead of masking a shape mismatch.
function applyProjection() {
const m = map.value
if (!m || typeof m.setProjection !== 'function') return
try { m.setProjection({ type: projection.value }); return } catch (_) { /* not the object form */ }
try { m.setProjection(projection.value) } catch (_) { /* engine has no projection support */ }
}
function toggleProjection() { projection.value = projection.value === 'globe' ? 'mercator' : 'globe'; applyProjection() }
// ── RDASH-ENHANCE: fly-to on selection. Any facet brush (tree node / chart slice / region / row)
@ -1428,8 +1536,18 @@ watch(() => JSON.stringify(filterSet), () => { clearTimeout(_flyT); _flyT = setT
useShadowStyles(`
.rd-mapwrap{ position:relative; width:100%; height:100%; min-height:0; background:var(--dx-line, #d1d4cd); }
/* ★ THE GALAXY IS THE CONTAINER, NOT THE TILES (Driver 2026-09-23, correcting the first attempt:
"I asked for a spherical globe with a black background — the galaxy — with blue oceans like the
Mapbox map, and the land was NOT to be black"). On a globe projection the sphere does not fill
the canvas; the corners show the CONTAINER's background. So space lives here: a deep radial
wash behind the sphere, while the tiles keep their normal daylight colours. Scoped to
[data-projection="globe"] so flat mercator keeps the neutral ground — space behind a flat
rectangle would read as a mistake. */
/* Plain BLACK behind the sphere (Driver: "if not, then just do black background, not grey").
On Mapbox the engine paints its own space + stars over the whole canvas via fog, so this only
shows on engines without fog support (MapLibre), where black is the honest fallback. */
.rd-mapwrap[data-projection="globe"]{ background:#000; }
.rd-mapwrap .rd-mapc{ position:absolute; inset:0; }
.rd-maptag{ position:absolute; left:8px; bottom:8px; z-index:2; font-size:10px; color:#33415588; background:#ffffffcc; padding:2px 6px; border-radius:4px; }
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
.rd-proj:hover{ background:#1a1a2e; }
@ -1464,7 +1582,8 @@ useShadowStyles(`
The fix RAISES THE LEGEND, and never touches the attribution: nothing here hides, shrinks
or restyles .mapboxgl-ctrl-attrib. --rd-attrib-clear is the reserved strip along the bottom
edge that the credit line owns; the legend's bottom edge starts above it.
Bottom-LEFT was not available — .rd-maptag already holds that corner.
Bottom-LEFT was not available when this was written (a caption held that corner; it has since
been removed, so the corner is free if a future control needs it).
Stays on the right so the legend sits beside the choropleth it explains. */
.rd-mapwrap{ --rd-attrib-clear:26px; }
.rd-legend-map{ position:absolute; right:8px; bottom:calc(var(--rd-attrib-clear,26px) + 8px); z-index:2;
@ -1525,7 +1644,6 @@ useShadowStyles(`
<span class="rd-proj" role="button" tabindex="0" data-proj-toggle @click="toggleProjection">
{{ projection === 'globe' ? '🌐 Globe' : '▭ Flat' }}
</span>
<div class="rd-maptag">UPG choropleth · click a country to filter · click a pin to select</div>
<div class="rd-legend-map" data-map-legend aria-label="UPG count legend">
<div class="lg-h">UPGs / country</div>
<div v-for="b in LEGEND_BINS" :key="b.l" class="lg-row">

View file

@ -165,12 +165,25 @@ const filters = computed(() => dropdownKeys.value.map((key) => ({
// ('Needs Prayer'), so passing it through a code resolver can only fail to find it and fall back —
// at best a no-op, at worst a blank chip. The value-is-label axes need no resolution by definition.
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
const activeChips = computed(() =>
Object.keys(filterSet).map((k) => {
const activeChips = computed(() => {
const out = Object.keys(filterSet).map((k) => {
const v = filterSet[k]
return { name: k, label: `${FACETS.value[k]?.label || k}: ${k === PRAYER_KEY ? v : labelFor(k, v)}` }
})
)
// ★ THE TYPED SEARCH IS A FILTER AND MUST LOOK LIKE ONE (Driver 2026-09-23: "I searched Bhutan
// and it did not switch the toggle to show the country was selected"). MEASURED: typing narrows
// the scope live via bus.searchTerm (2,107 -> 13) while filterSet stays empty, so the data
// changed and no control anywhere said why -- invisible state, the same class as the silent
// grey pane. The conventional faceted-search answer is that free text is its OWN chip beside
// the facet chips, clearable like any of them. It deliberately does NOT masquerade as a Country
// selection: the term matches across ALL searchKeys (name, region, language, religion...), so
// labelling it a country filter would lie whenever it matches something else.
// (First attempt went into DashboardFilterChips.vue -- a component the topbar dedup pass had
// already unmounted; THIS band is the documented single source of truth for chips.)
const term = (bus?.searchTerm?.value || '').trim()
if (term) out.push({ name: '__search__', label: `Search: ${term}` })
return out
})
const searchValue = computed(() => bus?.searchTerm?.value || '')
@ -178,8 +191,11 @@ const searchValue = computed(() => bus?.searchTerm?.value || '')
function onSearch(payload) { bus?.setSearch(payload?.value ?? '') }
function onFilterChange(detail) { bus?.setFilter(detail.name, detail.value) }
function onFilterClear(detail) { bus?.setFilter(detail.name, null) }
function onRemoveFilter(detail) { bus?.setFilter(detail.name, null) }
function onClearAll() { bus?.clearFilters() }
function onRemoveFilter(detail) {
if (detail.name === '__search__') { bus?.setSearch(''); return }
bus?.setFilter(detail.name, null)
}
function onClearAll() { bus?.clearFilters(); bus?.setSearch('') }
useShadowStyles(`
/* header-2 host: the research-filters band sits in the dashboard's dedicated 'filters' grid row.

View file

@ -4,7 +4,7 @@
* A dashboard shell (see plugin-designs/doxa-research-dashboards/research-dashboards.vue) is a CSS
* grid whose three flexible tracks are driven by CSS custom properties:
* --facet-w (left facet-rail column width) default 296px
* --rail-w (right charts-rail column width) default 264px
* --rail-w (right charts-rail column width) default 296px — equals --facet-w, so the map column sits centred
* --table-h (bottom table row height) default 34% (map above it is the 1fr hero)
*
* This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge),
@ -85,7 +85,30 @@ import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table
// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split;
// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above).
const DEFAULTS = { facetW: 296, railW: 264, tableH: '34%', mapH: 360, shellH: 720 }
// ★ shellH 720 -> 602 so the table OPENS at seven data rows (Driver 2026-09-23: "the table needs to
// only show SEVEN rows by DEFAULT height"). 602 = map 360 + bar 32 + 7 rows x 26 + foot 28.
// ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped
// the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes
// --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not
// be reached by any gesture. The Driver lost BOTH resizes at once and said so: "I didn't want the
// table set to 7. Only the table needs to be resizable. We've set it to where I can't even resize."
// Setting the STARTING VALUE instead leaves the derivation intact: the keyhole stays
// shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is
// merely where it opens.
// ★ RECLAIM PASS 2 (Driver 2026-09-23: "it is a priority to reclaim height... so I can see half
// of the first row of the people groups grid"). mapH 360 -> 300 and shellH follows the same
// arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table
// promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING
// MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a
// shorter map shows MORE world, not less.
// shellH 502 -> 542 (Driver 2026-09-23: "the dashboard should be taller by about 40px"). The
// extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot);
// the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in
// research-dashboard.vue kept byte-equal at 542.
// mapH 260 -> 300 (Driver 2026-09-23 follow-up: the +40px was to be "added to the map -- map is
// 40px taller display room"). shellH stays 542, so the table's derived share returns to its
// pre-+40 height and the whole gain lands in the map row.
const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 300, shellH: 542 }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] }
// Mirrors the CSS fallback --rd-table-bar-h (research-dashboard.vue's .rd-table-bar budget calc) —
@ -107,7 +130,15 @@ export function useDashboardLayout(options = {}) {
bus = null,
isNarrow = false,
pageScroll = false,
storageKey = 'rd-dashboard-layout',
// ★ KEY VERSIONED v2 (2026-09-23). The Driver reported "sidebars are not equally spaced" on
// his machine AFTER the symmetric-rail change landed -- because his browser restores a layout
// SAVED under the old defaults (rail 264), and a restored value outranks any new default by
// design. When defaults change materially, the old saved layouts are prescriptions from a
// different bottle: bump the key so every reader starts from the new defaults once, and a
// fresh drag re-personalises from there. The v1 blob is left in place, inert.
// v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted
// shellH:502 from the previous default would silently mask this change on the Driver's browser.
storageKey = 'rd-dashboard-layout-v4',
} = options
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }

View file

@ -23,6 +23,7 @@
* responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync.
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
*/
import { fuzzyMatches } from '@map/components/search/fuzzy.js'
import { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from './useSelectionBus.js'
import { useDashboardData } from './useDashboardData.js'
@ -59,12 +60,19 @@ export function buildSearchAccessors(config) {
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
*/
export function makeSearchMatcher(bus, accessors) {
// ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must
// still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with
// { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
// search tolerates typos. This matcher was a plain .includes() -- the one search surface that
// silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
// It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the
// grid's Fuse uses -- with the same DX-life options, so every search surface agrees.
return (row) => {
const needle = (bus?.searchTerm?.value || '').trim().toLowerCase()
const needle = (bus?.searchTerm?.value || '').trim()
if (!needle) return true
for (const acc of accessors) {
const v = acc(row)
if (v != null && String(v).toLowerCase().includes(needle)) return true
if (v != null && fuzzyMatches(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true
}
return false
}
@ -153,20 +161,61 @@ function useResponsiveShell(bus) {
/**
* Shareable view-state hash URL: serialize filterSet to location.hash; restore on mount.
* Extracted VERBATIM from research-dashboard.vue.
*
* ★ READABLE, CODE-CANONICAL FORMAT (card BUG-SHARE-URL-LEAKS-RAW-CODES). The old hash was
* URL-encoded JSON -- #f=%7B%22affinity_bloc%22%3A%22A008%22%7D -- unreadable twice over: JSON
* punctuation percent-escaped, and the value a bare API code. A URL is the one part of the
* product a user COPIES, PASTES and READS, and for sensitive population data a link you cannot
* sanity-check before clicking is a real cost. The format is now
*
* #f=affinity_bloc:A008*malay-peoples;religion_family:Hinduism
*
* -- the Stack Overflow /questions/123/slug pattern: the CODE is canonical and the *slug is
* DISPLAY-ONLY, appended from labelFor() at share time and STRIPPED on parse. Restoring never
* reads the slug, so links stay locale-independent and survive label renames (the card's
* explicit constraint: keep the code canonical, do not swap in localized labels).
*
* `*` is safe as the slug separator because escapeValue() percent-encodes any literal `*` in
* the VALUE first -- the separator cannot collide with content. `;` and `:` are legal fragment
* chars, so the whole thing stays legible in the address bar. Legacy JSON hashes (old shared
* links) are detected by their leading `{` / %7B and still restore.
*/
function useHashSync(bus) {
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
// Minimal escaping, maximal legibility: reserve our three delimiters + %, keep spaces as `+`
// (the x-www-form-urlencoded convention), leave everything else -- including unicode -- alone.
const escapeValue = (s) => String(s).replace(/[%;:*+]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
const unescapeValue = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, '%20')) } catch (_) { return String(s).replace(/\+/g, ' ') } }
function useHashSync(bus, labelFor) {
function syncHash() {
try {
const enc = encodeURIComponent(JSON.stringify(bus.filterSet))
if (typeof location !== 'undefined') history.replaceState(null, '', '#f=' + enc)
const pairs = Object.entries(bus.filterSet).map(([k, v]) => {
const label = typeof labelFor === 'function' ? labelFor(k, v) : null
const slug = label && String(label) !== String(v) ? slugify(label) : ''
return k + ':' + escapeValue(v) + (slug ? '*' + slug : '')
})
if (typeof location !== 'undefined') history.replaceState(null, '', '#f=' + pairs.join(';'))
} catch (_) { /* sandboxed */ }
}
watch(() => ({ ...bus.filterSet }), syncHash, { deep: true })
onMounted(() => {
try {
const m = typeof location !== 'undefined' && /[#&]f=([^&]+)/.exec(location.hash)
if (m) { const obj = JSON.parse(decodeURIComponent(m[1])); Object.entries(obj).forEach(([k, v]) => bus.setFilter(k, v)) }
if (!m) return
const raw = m[1]
if (raw.startsWith('{') || raw.startsWith('%7B') || raw.startsWith('%7b')) {
// legacy JSON hash from an old shared link
const obj = JSON.parse(decodeURIComponent(raw))
Object.entries(obj).forEach(([k, v]) => bus.setFilter(k, v))
return
}
for (const pair of raw.split(';')) {
const i = pair.indexOf(':')
if (i < 1) continue
const key = pair.slice(0, i)
const value = unescapeValue(pair.slice(i + 1).split('*')[0])
if (key && value) bus.setFilter(key, value)
}
} catch (_) { /* ignore bad hash */ }
})
}
@ -210,9 +259,6 @@ export function useDashboardShell(config) {
// responsive flags + collapsible table dock + segmented mobile view.
const responsive = useResponsiveShell(bus)
// shareable view-state hash URL.
useHashSync(bus)
// ── dataStore adapter (GAP B) — DataSourceManager-normalized (DASH-FIX-2) ──
// The header's GeocoderComponent bars (DOXA-LIB-3) run useEntitySearch over an
// injected `dataStore` to surface local people-group / country / language /
@ -259,6 +305,10 @@ export function useDashboardShell(config) {
// here, below `dataStore`, because the resolver reads the normalized features it exposes.
// Data columns are unaffected (see useCsvExport.buildCsv).
const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId)
// shareable view-state hash URL -- below labelFor on purpose: the hash's display-only slugs
// resolve through the SAME code->name resolver the chips and the CSV provenance line use.
useHashSync(bus, labelFor)
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor })
provide('selectionBus', bus)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-ajyiOLGnsr8AOhrZpwopjFXwKGNk56IbWkBMD+2oYA+DSDjdtATeoF5muZoXhcEF" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-xK62b4nEd/ei3xDdRNOOCl9YyDRliPUvS8dtna07xFFR1nwPqaCiAHBNuxRtuaIH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../big-picture.js" integrity="sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-5cdQ8p58ktHysAAacoO4Fa71ukeM0ZFQO3lFnzL51DdNBmrxugr0hd7nG3lmIZ5W" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-5cdQ8p58ktHysAAacoO4Fa71ukeM0ZFQO3lFnzL51DdNBmrxugr0hd7nG3lmIZ5W" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../demo-map.js" integrity="sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO" crossorigin="anonymous"></script>
<script src="../demo-map.js" integrity="sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -27,7 +27,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../../demo-map.js" integrity="sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO" crossorigin="anonymous"></script>
<script src="../../demo-map.js" integrity="sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-ugm8PXu3KYQmGJoQnOUaICqt07r17kQa1bINkKsrUgt89uENqvd87rxT2NBeyC9/" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-RNehzQHiymgsOQl1VH//TZQeAQyx1F3W7tBEjS/o02V9GoAaqFISm8gt6xFn0xse" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
<script src="../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,7 @@
"bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "doxa-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-ajyiOLGnsr8AOhrZpwopjFXwKGNk56IbWkBMD+2oYA+DSDjdtATeoF5muZoXhcEF",
"integrity": "sha384-xK62b4nEd/ei3xDdRNOOCl9YyDRliPUvS8dtna07xFFR1nwPqaCiAHBNuxRtuaIH",
"elements": [
"all-people-groups"
],
@ -25,7 +25,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-ajyiOLGnsr8AOhrZpwopjFXwKGNk56IbWkBMD+2oYA+DSDjdtATeoF5muZoXhcEF\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-xK62b4nEd/ei3xDdRNOOCl9YyDRliPUvS8dtna07xFFR1nwPqaCiAHBNuxRtuaIH\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
},
"bundle": "all-people-groups"
}
@ -35,7 +35,7 @@
"bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js",
"output": "doxa-maps/big-picture/big-picture.js",
"integrity": "sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43",
"integrity": "sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4",
"elements": [
"bp-big-picture",
"bp-research-header",
@ -57,7 +57,7 @@
"scriptUrl": "./big-picture/big-picture.js",
"embed": {
"iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
},
"instances": [
{
@ -154,7 +154,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "doxa-maps/country-posters/country-posters.js",
"integrity": "sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO",
"integrity": "sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55",
"elements": [
"country-posters-map"
],
@ -170,7 +170,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -184,7 +184,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -193,7 +193,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0",
"integrity": "sha384-5cdQ8p58ktHysAAacoO4Fa71ukeM0ZFQO3lFnzL51DdNBmrxugr0hd7nG3lmIZ5W",
"elements": [
"dashboard-plus-grid"
],
@ -213,7 +213,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-5cdQ8p58ktHysAAacoO4Fa71ukeM0ZFQO3lFnzL51DdNBmrxugr0hd7nG3lmIZ5W\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -237,7 +237,7 @@
"bundle": "demo-map",
"scriptUrl": "./demo-map/demo-map.js",
"output": "doxa-maps/demo-map/demo-map.js",
"integrity": "sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO",
"integrity": "sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D",
"elements": [
"demo-map"
],
@ -253,7 +253,7 @@
"scriptUrl": "./demo-map/demo-map.js",
"embed": {
"iframe": "<iframe src=\"./demo-map/demo-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
},
"instances": [
{
@ -280,7 +280,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c",
"integrity": "sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP",
"elements": [
"doxa-maps-spa"
],
@ -304,7 +304,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{
@ -331,7 +331,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-ugm8PXu3KYQmGJoQnOUaICqt07r17kQa1bINkKsrUgt89uENqvd87rxT2NBeyC9/",
"integrity": "sha384-RNehzQHiymgsOQl1VH//TZQeAQyx1F3W7tBEjS/o02V9GoAaqFISm8gt6xFn0xse",
"elements": [
"doxa-research-map"
],
@ -347,7 +347,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-ugm8PXu3KYQmGJoQnOUaICqt07r17kQa1bINkKsrUgt89uENqvd87rxT2NBeyC9/\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-RNehzQHiymgsOQl1VH//TZQeAQyx1F3W7tBEjS/o02V9GoAaqFISm8gt6xFn0xse\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
}
}
]
@ -356,7 +356,7 @@
"bundle": "doxa-simple-map",
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"output": "doxa-maps/doxa-simple-map/doxa-simple-map.js",
"integrity": "sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu",
"integrity": "sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -373,7 +373,7 @@
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
},
"instances": [
{
@ -623,7 +623,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE",
"integrity": "sha384-we1vxOSsKO6L9jEWyO9RgD7mwBuR9HsBiCkk7vYS/zAADO3T7oXkpED2XeOd8tzO",
"elements": [
"my-upg-100-list-map"
],
@ -643,7 +643,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-we1vxOSsKO6L9jEWyO9RgD7mwBuR9HsBiCkk7vYS/zAADO3T7oXkpED2XeOd8tzO\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -657,7 +657,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-we1vxOSsKO6L9jEWyO9RgD7mwBuR9HsBiCkk7vYS/zAADO3T7oXkpED2XeOd8tzO\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -671,7 +671,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-we1vxOSsKO6L9jEWyO9RgD7mwBuR9HsBiCkk7vYS/zAADO3T7oXkpED2XeOd8tzO\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -680,7 +680,7 @@
"bundle": "people-groups-grid",
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"output": "doxa-maps/people-groups-grid/people-groups-grid.js",
"integrity": "sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg",
"integrity": "sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq",
"elements": [
"people-groups-grid"
],
@ -696,7 +696,7 @@
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"embed": {
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
},
"instances": [
{
@ -741,7 +741,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "doxa-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp",
"integrity": "sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH",
"elements": [
"plugin-studio"
],
@ -769,7 +769,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{
@ -796,7 +796,7 @@
"bundle": "prayer-walk-planner",
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
"integrity": "sha384-+XBRYgeeX8JLpdYcc+oGtkQf+Zgc2JkFK6/tlMBh5qghF9rNAJLk1lwVxWkU0cKx",
"integrity": "sha384-/lrJX10JhiaXgpDAv0tf4R7ycgOaBRG+8oDasqHnD9MomT8ZGiykoh5+/vCJSJsr",
"elements": [
"prayer-walk-planner"
],
@ -812,7 +812,7 @@
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"embed": {
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-+XBRYgeeX8JLpdYcc+oGtkQf+Zgc2JkFK6/tlMBh5qghF9rNAJLk1lwVxWkU0cKx\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-/lrJX10JhiaXgpDAv0tf4R7ycgOaBRG+8oDasqHnD9MomT8ZGiykoh5+/vCJSJsr\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
}
}
]
@ -821,7 +821,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv",
"integrity": "sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan",
"elements": [
"regions-dashboard"
],
@ -841,7 +841,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -855,7 +855,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -869,7 +869,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]
@ -878,7 +878,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "doxa-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-sQL0nI6tuJD+PpCrg4TbNH5QwazCgMmk/oLwCH7QVbtoh4uYdoPhIPdokF1WLTgT",
"integrity": "sha384-TSoH4Y3PWqIII0JP8c5QCi67iSGzwDzp0DovZ/4JhszwI61XJd7sjc2xwI0EvbCU",
"elements": [
"research-dashboard-map"
],
@ -894,7 +894,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-sQL0nI6tuJD+PpCrg4TbNH5QwazCgMmk/oLwCH7QVbtoh4uYdoPhIPdokF1WLTgT\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-TSoH4Y3PWqIII0JP8c5QCi67iSGzwDzp0DovZ/4JhszwI61XJd7sjc2xwI0EvbCU\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-we1vxOSsKO6L9jEWyO9RgD7mwBuR9HsBiCkk7vYS/zAADO3T7oXkpED2XeOd8tzO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-we1vxOSsKO6L9jEWyO9RgD7mwBuR9HsBiCkk7vYS/zAADO3T7oXkpED2XeOd8tzO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-we1vxOSsKO6L9jEWyO9RgD7mwBuR9HsBiCkk7vYS/zAADO3T7oXkpED2XeOd8tzO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../people-groups-grid.js" integrity="sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg" crossorigin="anonymous"></script>
<script src="../people-groups-grid.js" integrity="sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../plugin-studio.js" integrity="sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp" crossorigin="anonymous"></script>
<script src="../plugin-studio.js" integrity="sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../../plugin-studio.js" integrity="sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp" crossorigin="anonymous"></script>
<script src="../../plugin-studio.js" integrity="sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
<script src="../prayer-walk-planner.js" integrity="sha384-+XBRYgeeX8JLpdYcc+oGtkQf+Zgc2JkFK6/tlMBh5qghF9rNAJLk1lwVxWkU0cKx" crossorigin="anonymous"></script>
<script src="../prayer-walk-planner.js" integrity="sha384-/lrJX10JhiaXgpDAv0tf4R7ycgOaBRG+8oDasqHnD9MomT8ZGiykoh5+/vCJSJsr" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-sQL0nI6tuJD+PpCrg4TbNH5QwazCgMmk/oLwCH7QVbtoh4uYdoPhIPdokF1WLTgT" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-TSoH4Y3PWqIII0JP8c5QCi67iSGzwDzp0DovZ/4JhszwI61XJd7sjc2xwI0EvbCU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: