Regions element gets the Select View tabs, shared with UPG-100
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
The Regions bundle shipped only tab 1, so the Select View switcher the UPG-100 list shows had nowhere to live. Adds the composite that carries it over two panes -- Regions List and Region Dashboards -- plus the Region Dashboards tab itself, which summarises a region or a country from the same live endpoint and the same rollup tab 1 uses. Clicking a region heading or a country row on tab 1 now forwards that selection and swaps to tab 2 with it applied. The switcher itself is extracted into library/components/ViewSelect.vue rather than copied a third time: it had two implementations already, in the app shell and hand-copied into upg-100-mfe. It takes the views and the current id as props and knows nothing about maps or panes, so the placement stays with each header. UPG-100 now renders from the shared component and is unchanged on screen -- same 45px height, 13px labels, 8px radius.
This commit is contained in:
parent
3e95c6bbee
commit
bca3d58c3e
9 changed files with 989 additions and 45 deletions
92
library/components/ViewSelect.vue
Normal file
92
library/components/ViewSelect.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<!--
|
||||
ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
|
||||
underline-indicator tabs.
|
||||
|
||||
THE CANONICAL SOURCE is the app shell, layouts/app-wrapper-material/index.html
|
||||
(`.viewsel` / `.seg`, a PrimeVue-style Fieldset). This component is that control
|
||||
extracted VERBATIM — same structure (semantic <fieldset>/<legend>), same metrics
|
||||
(8px radius, 10px/700/.7px-tracking uppercase legend, 13px/500 buttons with
|
||||
6px 12px 7px padding), same 2px accent underline on the pressed tab.
|
||||
|
||||
WHY IT LIVES IN THE LIBRARY. It had two independent implementations before this
|
||||
file existed — the shell's vanilla one and a hand-copied Vue port inside
|
||||
my-upg-100-list/upg-100-mfe.vue — and a third consumer (regions-dashboard) was
|
||||
about to copy it again. A control with three consumers and no map-specific
|
||||
assumptions is what this library is for; a fourth divergent copy is not.
|
||||
|
||||
PARAMETERIZED, per library/components/WARNING.md — it takes props, not assumptions:
|
||||
it knows nothing about maps, panes, bundles or scrolling. It renders the views it
|
||||
is handed and reports which one was clicked. Positioning is the CALLER's job:
|
||||
this component ships no `position`/`top`/`left`, so a header that wants it
|
||||
absolutely centred styles `.viewsel` itself (as both the shell and the MFE do).
|
||||
|
||||
Props:
|
||||
views — [{ id, label }, …] the views to offer, in order
|
||||
current — id of the active view
|
||||
legend — the fieldset's legend text (default "Select View")
|
||||
label — accessible name for the tablist (default "Views")
|
||||
Emits:
|
||||
select(id) — a view's tab was clicked
|
||||
-->
|
||||
<script setup>
|
||||
import { useShadowStyles } from '../composables/useShadowStyles.js'
|
||||
|
||||
defineProps({
|
||||
views: { type: Array, default: () => [] },
|
||||
current: { type: String, default: '' },
|
||||
legend: { type: String, default: 'Select View' },
|
||||
label: { type: String, default: 'Views' },
|
||||
})
|
||||
|
||||
defineEmits(['select'])
|
||||
|
||||
// Child components' <style> blocks are dropped by defineCustomElement, so the CSS
|
||||
// goes through useShadowStyles like every other library component.
|
||||
//
|
||||
// NO BACKTICKS ANYWHERE IN THIS STRING, including in comments: one stray backtick
|
||||
// terminates the JS template literal and the build stops compiling. Hence string
|
||||
// concatenation rather than one template literal (house style, AGENT-BRIEF.md).
|
||||
useShadowStyles(
|
||||
// Metrics copied from the shell's .viewsel; colours come from the ui-* custom
|
||||
// properties the host element already declares, with the material-light palette
|
||||
// as the fallback so this renders correctly even outside a themed host.
|
||||
'.viewsel{ margin:0; padding:0 10px 2px;' +
|
||||
' border:1px solid var(--ui-border,#d1d4cd); border-radius:8px;' +
|
||||
' background:var(--ui-bg,#ffffff); }' +
|
||||
// Legend default alignment is inline-start; the shell centres it.
|
||||
'.viewsel > legend{ margin:0 auto; padding:0 6px; font-size:10px;' +
|
||||
' text-transform:uppercase; letter-spacing:.7px;' +
|
||||
' color:var(--ui-text-muted,#6d766e); font-weight:700; }' +
|
||||
// Slim UNDERLINE indicator, not a filled button.
|
||||
'.seg{ display:flex; gap:2px; }' +
|
||||
'.seg button{ position:relative; display:flex; align-items:center; gap:6px;' +
|
||||
' border:0; background:none; color:var(--ui-text-muted,#6d766e);' +
|
||||
' padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;' +
|
||||
' transition:color .15s; font-family:inherit; }' +
|
||||
'.seg button::after{ content:""; position:absolute; left:8px; right:8px; bottom:0;' +
|
||||
' height:2px; border-radius:2px 2px 0 0; background:transparent;' +
|
||||
' transition:background .15s; }' +
|
||||
'.seg button:hover{ color:var(--ui-text,#3b463d); }' +
|
||||
'.seg button:hover::after{ background:var(--ui-border,#d1d4cd); }' +
|
||||
'.seg button[aria-pressed="true"]{ color:var(--ui-primary,#92b195); font-weight:600; }' +
|
||||
'.seg button[aria-pressed="true"]::after{ background:var(--ui-primary,#92b195); }',
|
||||
'view-select'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="viewsel">
|
||||
<legend>{{ legend }}</legend>
|
||||
<div class="seg" role="tablist" :aria-label="label">
|
||||
<button
|
||||
v-for="v in views"
|
||||
:key="v.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-pressed="current === v.id ? 'true' : 'false'"
|
||||
:aria-selected="current === v.id ? 'true' : 'false'"
|
||||
@click="$emit('select', v.id)"
|
||||
>{{ v.label }}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
|
@ -10,7 +10,9 @@
|
|||
* 'upg-100-list': it composes the bundle's two sibling elements — the UPG-100
|
||||
* map (`upg-100-list`) and the poster wall (`poster-wall`) — as PANES using the
|
||||
* SAME pane-wrapper layout the shell nests (buildPaneColumn + PANE_GRID_STYLES),
|
||||
* and the SAME View switcher (.viewsel / .seg) the shell shows in its header.
|
||||
* and the SAME View switcher the shell shows in its header — now the shared
|
||||
* components/ViewSelect.vue, which is that control extracted from this file so
|
||||
* 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
|
||||
|
|
@ -33,6 +35,7 @@
|
|||
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import ViewSelect from '@map/components/ViewSelect.vue'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
|
||||
const cfg = inject('profileConfig', ref(null))
|
||||
|
|
@ -167,31 +170,15 @@ useShadowStyles(PANE_GRID_STYLES + `
|
|||
display:flex; align-items:center; justify-content:flex-end; padding:0 12px;
|
||||
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
|
||||
}
|
||||
/* ── Select View fieldset (copied from the shell; centred in the header) ── */
|
||||
/* ── Select View fieldset ──
|
||||
The control's own metrics now live in the SHARED library component
|
||||
(components/ViewSelect.vue) — this file used to carry a hand-copied second
|
||||
implementation of the shell's .viewsel/.seg rules. Only the PLACEMENT stays
|
||||
here, because centring in this particular header is this layout's decision,
|
||||
not the control's. */
|
||||
.viewsel{
|
||||
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
|
||||
margin:0; padding:0 10px 2px; border:1px solid var(--ui-border);
|
||||
border-radius:8px; background:var(--ui-bg);
|
||||
}
|
||||
.viewsel > legend{
|
||||
margin:0 auto; padding:0 6px; font-size:10px; text-transform:uppercase;
|
||||
letter-spacing:.7px; color:var(--ui-text-muted); font-weight:700;
|
||||
}
|
||||
.seg{display:flex;gap:2px}
|
||||
.seg button{
|
||||
position:relative; display:flex; align-items:center; gap:6px;
|
||||
border:0; background:none; color:var(--ui-text-muted);
|
||||
padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;
|
||||
transition:color .15s; font-family:inherit;
|
||||
}
|
||||
.seg button::after{
|
||||
content:''; position:absolute; left:8px; right:8px; bottom:0; height:2px;
|
||||
border-radius:2px 2px 0 0; background:transparent; transition:background .15s;
|
||||
}
|
||||
.seg button:hover{color:var(--ui-text)}
|
||||
.seg button:hover::after{background:var(--ui-border)}
|
||||
.seg button[aria-pressed="true"]{color:var(--ui-primary);font-weight:600}
|
||||
.seg button[aria-pressed="true"]::after{background:var(--ui-primary)}
|
||||
/* ── stage: the pane-wrapper column mounts here (its .pane-col is absolute) ── */
|
||||
.upgmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
|
||||
/* the live custom element fills its pane */
|
||||
|
|
@ -202,18 +189,13 @@ useShadowStyles(PANE_GRID_STYLES + `
|
|||
<template>
|
||||
<div class="upgmfe">
|
||||
<header class="hdr">
|
||||
<fieldset class="viewsel">
|
||||
<legend>Select View</legend>
|
||||
<div class="seg" role="tablist" aria-label="Views">
|
||||
<button
|
||||
v-for="p in spec.panes" :key="p.id"
|
||||
type="button" role="tab"
|
||||
:aria-pressed="view === p.id ? 'true' : 'false'"
|
||||
:aria-selected="view === p.id ? 'true' : 'false'"
|
||||
@click="go(p.id)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<ViewSelect
|
||||
:views="spec.panes"
|
||||
:current="view"
|
||||
legend="Select View"
|
||||
label="Views"
|
||||
@select="go"
|
||||
/>
|
||||
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
|
||||
</header>
|
||||
<div class="stage" ref="stage"></div>
|
||||
|
|
|
|||
|
|
@ -22,16 +22,30 @@
|
|||
<body>
|
||||
<div class="wrap">
|
||||
<h1>regions-dashboard · staging</h1>
|
||||
<p class="sub">Tab 1, "Regions List" — an independently scrollable list of WAGF regions and
|
||||
their countries. Clicking a region or country emits a selection event
|
||||
(<code>region-selected</code> / <code>country-selected</code>) rather than navigating.</p>
|
||||
<p class="sub">The 2-tab Regions element: a SELECT VIEW switcher over "Regions List" and
|
||||
"Region Dashboards". Clicking a region or country on tab 1 swaps to tab 2 with that
|
||||
selection applied. Each tab is also mountable on its own, below.</p>
|
||||
|
||||
<div class="slot">
|
||||
<div class="label">profile-config.profile = "regions-list"</div>
|
||||
<div class="label">profile-config.profile = "regions-mfe" — the composite (both tabs)</div>
|
||||
<div class="frame">
|
||||
<regions-dashboard profile-config='{"profile":"regions-mfe"}'></regions-dashboard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slot" style="margin-top:24px">
|
||||
<div class="label">profile-config.profile = "regions-list" — tab 1 alone</div>
|
||||
<div class="frame">
|
||||
<regions-dashboard profile-config='{"profile":"regions-list"}'></regions-dashboard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slot" style="margin-top:24px">
|
||||
<div class="label">profile-config.profile = "region-dashboards" — tab 2 alone</div>
|
||||
<div class="frame">
|
||||
<regions-dashboard profile-config='{"profile":"region-dashboards"}'></regions-dashboard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@
|
|||
*
|
||||
* Bundle entry — registers the regions-dashboard web component.
|
||||
*
|
||||
* This bundle currently ships ONE flat profile owned by this slice — regions-list.vue
|
||||
* ("Regions List", tab 1 of the Driver's 2-tab Regions Dashboard). A teammate owns tab 2
|
||||
* ("Region Dashboards") and the shell that hosts both tabs; their profile(s) land as
|
||||
* additional flat .vue files in this same folder and are picked up automatically by the
|
||||
* glob below — no change needed here when that lands.
|
||||
* This bundle ships the Driver's 2-tab Regions element as three flat profiles:
|
||||
* regions-list.vue — tab 1, "Regions List"
|
||||
* region-dashboards.vue — tab 2, "Region Dashboards"
|
||||
* regions-mfe.vue — the COMPOSITE that carries the SELECT VIEW switcher over both
|
||||
* tabs and forwards tab 1's selection to tab 2
|
||||
*
|
||||
* The composite nests the two tabs as <regions-dashboard> custom elements of this SAME bundle,
|
||||
* each with its own `profile` — so one download serves all three, exactly as my-upg-100-list's
|
||||
* upg-100-mfe does. That is why the glob below must keep matching every flat .vue here.
|
||||
*
|
||||
* Usage from a host page:
|
||||
* <script src="/doxa-maps/regions-dashboard/regions-dashboard.js"></script>
|
||||
* <regions-dashboard profile-config='{"profile":"regions-list"}'></regions-dashboard>
|
||||
* <regions-dashboard profile-config='{"profile":"regions-mfe"}'></regions-dashboard>
|
||||
*
|
||||
* `profile` must match a flat .vue filename in this folder (without the .vue).
|
||||
* The profile-config attribute is parsed by ProfileLoader (in @map).
|
||||
|
|
|
|||
317
plugin-designs/regions-dashboard/region-dashboards.vue
Normal file
317
plugin-designs/regions-dashboard/region-dashboards.vue
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
<script setup>
|
||||
/**
|
||||
* region-dashboards.vue — Tab 2 ("Region Dashboards") of the regions-dashboard bundle.
|
||||
*
|
||||
* @description The dashboard half of the Driver's 2-tab Regions element: given a region (or a
|
||||
* single country inside one), it summarises that selection — headline stats plus a comparison
|
||||
* of its countries — using the SAME live people-groups endpoint and the SAME rollup fields as
|
||||
* regions-list.vue (tab 1), so the two tabs can never disagree about a number.
|
||||
*
|
||||
* HOW IT LEARNS WHAT TO SHOW. This element is deliberately not wired to tab 1 directly. It takes
|
||||
* its selection from its own `profile-config` (keys `regionSlug` / `countrySlug`) and ALSO listens
|
||||
* for a scoped window event, 'doxa-regions-dashboard:select'. The composite (regions-mfe.vue)
|
||||
* nests both tabs as sibling custom elements and forwards tab 1's `region-selected` /
|
||||
* `country-selected` onto that event, scoped by instanceId — the same cross-pane seam
|
||||
* upg-100-mfe.vue uses for the poster wall's 'doxa-upg-100-list:show-on-map'. Keeping the seam an
|
||||
* EVENT (not a prop) is what lets each tab keep its own ProfileLoader/Pinia and still stay in sync.
|
||||
*
|
||||
* WITH NO SELECTION it shows every region compared against each other, so the tab is meaningful
|
||||
* standalone (and on first paint, before anything has been clicked) rather than an empty state.
|
||||
*
|
||||
* SOURCE for the grouping logic: regions-list.vue's summarizeRegions(), itself a copy of DX-life's
|
||||
* config/countries-meta.ts. Copied rather than imported for the same reason stated there — and
|
||||
* kept field-for-field identical so both tabs roll up the same way.
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import BarChart from '@map/components/charts/BarChart.vue'
|
||||
|
||||
const props = defineProps({
|
||||
profileConfig: { type: String, default: '' },
|
||||
prayBaseUrl: { type: String, default: '' },
|
||||
languageCode: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const cfg = computed(() => {
|
||||
if (!props.profileConfig) return {}
|
||||
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
|
||||
})
|
||||
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
|
||||
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
|
||||
const instanceId = computed(() => cfg.value.instanceId || '')
|
||||
|
||||
// ── state ───────────────────────────────────────────────────────────────────
|
||||
const regions = ref([])
|
||||
const loading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
// The current selection, as { regionSlug, countrySlug } — seeded from profile-config so a
|
||||
// standalone embed can deep-link straight to one region's dashboard.
|
||||
const sel = ref({
|
||||
regionSlug: cfg.value.regionSlug || '',
|
||||
countrySlug: cfg.value.countrySlug || '',
|
||||
})
|
||||
|
||||
// ── identical field list + rollup to tab 1 ──────────────────────────────────
|
||||
const FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
|
||||
|
||||
function toNumber(v) {
|
||||
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0)
|
||||
return Number.isNaN(n) ? 0 : n
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return String(name || '')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/['\u2019.]/g, '')
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
const FULL_PRAYER_COVERAGE_COUNT = 100
|
||||
|
||||
function emptyStats() {
|
||||
return { count: 0, population: 0, adopted: 0, engaged: 0, withPrayer: 0, withFullPrayer: 0, peopleCommitted: 0 }
|
||||
}
|
||||
|
||||
function addToStats(stats, p) {
|
||||
const committed = toNumber(p.people_committed)
|
||||
stats.count++
|
||||
stats.population += toNumber(p.population)
|
||||
if ((p.adopted_by_churches ?? 0) > 0) stats.adopted++
|
||||
if (p.engagement_status?.value === 'engaged') stats.engaged++
|
||||
if (committed > 0) stats.withPrayer++
|
||||
if (committed >= FULL_PRAYER_COVERAGE_COUNT) stats.withFullPrayer++
|
||||
stats.peopleCommitted += committed
|
||||
}
|
||||
|
||||
function summarizeRegions(posts) {
|
||||
const countryGroups = new Map()
|
||||
for (const p of posts) {
|
||||
const code = p.country_code?.value
|
||||
if (!code) continue
|
||||
let g = countryGroups.get(code)
|
||||
if (!g) {
|
||||
g = { name: p.country_code.label, region: { value: '', label: '' }, stats: emptyStats() }
|
||||
countryGroups.set(code, g)
|
||||
}
|
||||
if (!g.region.value && p.wagf_region?.value) g.region = p.wagf_region
|
||||
addToStats(g.stats, p)
|
||||
}
|
||||
|
||||
const countries = []
|
||||
for (const [code, g] of countryGroups) {
|
||||
countries.push({ slug: slugify(g.name) || slugify(code) || code.toLowerCase(), code, name: g.name, region: g.region, ...g.stats })
|
||||
}
|
||||
countries.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const regionSlugByCountry = new Map(countries.map((c) => [c.code, slugify(c.region.value) || 'other']))
|
||||
const regionGroups = new Map()
|
||||
for (const c of countries) {
|
||||
const slug = regionSlugByCountry.get(c.code)
|
||||
let g = regionGroups.get(slug)
|
||||
if (!g) {
|
||||
g = { value: c.region.value, label: c.region.label, countries: [], stats: emptyStats() }
|
||||
regionGroups.set(slug, g)
|
||||
}
|
||||
g.countries.push(c)
|
||||
}
|
||||
for (const p of posts) {
|
||||
const code = p.country_code?.value
|
||||
if (!code) continue
|
||||
const g = regionGroups.get(regionSlugByCountry.get(code))
|
||||
if (g) addToStats(g.stats, p)
|
||||
}
|
||||
|
||||
const out = []
|
||||
for (const [slug, g] of regionGroups) {
|
||||
out.push({ slug, value: g.value, label: g.label, countryCount: g.countries.length, countries: g.countries, ...g.stats })
|
||||
}
|
||||
out.sort((a, b) => (a.label || 'Other').localeCompare(b.label || 'Other'))
|
||||
return out
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
return Number(n || 0).toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// ── the selected subject ────────────────────────────────────────────────────
|
||||
const selectedRegion = computed(() =>
|
||||
regions.value.find((r) => r.slug === sel.value.regionSlug) || null
|
||||
)
|
||||
const selectedCountry = computed(() => {
|
||||
if (!sel.value.countrySlug) return null
|
||||
const r = selectedRegion.value
|
||||
const pool = r ? r.countries : regions.value.flatMap((x) => x.countries)
|
||||
return pool.find((c) => c.slug === sel.value.countrySlug) || null
|
||||
})
|
||||
|
||||
// The subject line — a country if one was clicked, else the region, else everything.
|
||||
const subject = computed(() => {
|
||||
if (selectedCountry.value) {
|
||||
const c = selectedCountry.value
|
||||
return { kind: 'country', title: c.name, context: c.region?.label || 'Other', stats: c }
|
||||
}
|
||||
if (selectedRegion.value) {
|
||||
const r = selectedRegion.value
|
||||
return { kind: 'region', title: r.label || 'Other', context: `${formatNumber(r.countryCount)} countries`, stats: r }
|
||||
}
|
||||
const all = emptyStats()
|
||||
for (const r of regions.value) {
|
||||
all.count += r.count; all.population += r.population; all.adopted += r.adopted
|
||||
all.engaged += r.engaged; all.withPrayer += r.withPrayer
|
||||
all.withFullPrayer += r.withFullPrayer; all.peopleCommitted += r.peopleCommitted
|
||||
}
|
||||
return { kind: 'all', title: 'All Regions', context: `${formatNumber(regions.value.length)} regions`, stats: all }
|
||||
})
|
||||
|
||||
// Headline tiles. Every one of these is a COUNT the rollup actually measured — no ratio is
|
||||
// invented here that tab 1 could not also produce from the same records.
|
||||
const tiles = computed(() => {
|
||||
const s = subject.value.stats || emptyStats()
|
||||
return [
|
||||
{ label: 'People groups', value: formatNumber(s.count) },
|
||||
{ label: 'Population', value: formatNumber(s.population) },
|
||||
{ label: 'Engaged', value: formatNumber(s.engaged) },
|
||||
{ label: 'With prayer cover', value: formatNumber(s.withPrayer) },
|
||||
]
|
||||
})
|
||||
|
||||
// The comparison bars: a region's countries against each other, or all regions against each
|
||||
// other when nothing is selected. A country selection keeps its region's bars so the country
|
||||
// stays legible IN CONTEXT rather than as one lone bar.
|
||||
const chartRows = computed(() => {
|
||||
const r = selectedRegion.value
|
||||
const rows = r ? r.countries : regions.value
|
||||
return rows
|
||||
.map((x) => ({
|
||||
label: x.name || x.label || 'Other',
|
||||
value: x.count,
|
||||
// Highlight the selected country inside its region's chart.
|
||||
color: selectedCountry.value && x.slug === selectedCountry.value.slug ? '#3b463d' : '#92b195',
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 24)
|
||||
})
|
||||
|
||||
const chartTitle = computed(() =>
|
||||
selectedRegion.value
|
||||
? `People groups by country — ${selectedRegion.value.label || 'Other'}`
|
||||
: 'People groups by region'
|
||||
)
|
||||
|
||||
// ── cross-pane seam: the composite forwards tab 1's selection here ──────────
|
||||
const SELECT_EVENT = 'doxa-regions-dashboard:select'
|
||||
|
||||
function onSelect(e) {
|
||||
const d = e?.detail || {}
|
||||
// Scoped by instanceId exactly as upg-100-mfe's show-on-map seam is: an event carrying a
|
||||
// DIFFERENT instance's id belongs to another embed on the same page and must be ignored.
|
||||
if (d.instanceId && instanceId.value && d.instanceId !== instanceId.value) return
|
||||
sel.value = { regionSlug: d.regionSlug || '', countrySlug: d.countrySlug || '' }
|
||||
}
|
||||
|
||||
// ── data fetch ──────────────────────────────────────────────────────────────
|
||||
async function loadRegions() {
|
||||
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=${FIELDS}&lang=${languageCode.value}`
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const data = await response.json()
|
||||
const posts = Array.isArray(data.posts) ? data.posts : []
|
||||
regions.value = summarizeRegions(posts)
|
||||
} catch (error) {
|
||||
console.error('[region-dashboards] fetch error:', error)
|
||||
loadFailed.value = true
|
||||
regions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(SELECT_EVENT, onSelect)
|
||||
loadRegions()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(SELECT_EVENT, onSelect)
|
||||
})
|
||||
|
||||
// Palette is DX-life material light, matching regions-list.vue tab-for-tab so swapping between
|
||||
// the two tabs does not change the surface under the reader.
|
||||
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
|
||||
useShadowStyles(
|
||||
':host{ display:block; width:100%; height:100%; min-height:320px; }' +
|
||||
'.rd-root{ position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
|
||||
' --dx-surface:#f3f3f1; --dx-elevated:#fff; --dx-text:#2a332a; --dx-header-bg:#3b463d;' +
|
||||
' --dx-header-text:#fff; --dx-muted:#6d766e; --dx-line:#d1d4cd; --dx-accent:#92b195;' +
|
||||
' background:var(--dx-surface); color:var(--dx-text);' +
|
||||
' font:16px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }' +
|
||||
'.rd-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;' +
|
||||
' padding:20px; display:flex; flex-direction:column; gap:20px; }' +
|
||||
'.rd-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:18px; }' +
|
||||
'.rd-status.rd-failed{ font-weight:600; }' +
|
||||
// Subject header: the same dark brand band tab 1 puts at the top of each region card, so the
|
||||
// two tabs read as one element rather than two designs.
|
||||
'.rd-subject{ flex:0 0 auto; border-radius:24px; overflow:hidden;' +
|
||||
' background:var(--dx-header-bg); color:var(--dx-header-text); padding:20px 28px; }' +
|
||||
'.rd-subject h2{ margin:0; font-size:34px; line-height:1.1;' +
|
||||
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;' +
|
||||
' font-weight:400; font-stretch:condensed; }' +
|
||||
'.rd-subject p{ margin:6px 0 0; font-size:16px; opacity:0.85; }' +
|
||||
// Stat tiles.
|
||||
'.rd-tiles{ flex:0 0 auto; display:grid; gap:12px;' +
|
||||
' grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); }' +
|
||||
'.rd-tile{ background:var(--dx-elevated); border-radius:18px; padding:18px 22px;' +
|
||||
' border:1px solid var(--dx-line); }' +
|
||||
'.rd-tile__value{ font-size:28px; font-weight:600; line-height:1.15; }' +
|
||||
'.rd-tile__label{ margin-top:4px; font-size:14px; color:var(--dx-muted); }' +
|
||||
// Chart card.
|
||||
'.rd-chart{ flex:0 0 auto; background:var(--dx-elevated); border-radius:18px;' +
|
||||
' border:1px solid var(--dx-line); padding:18px 22px; }' +
|
||||
'.rd-hint{ flex:0 0 auto; color:var(--dx-muted); font-size:14px; }',
|
||||
'region-dashboards'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-root">
|
||||
<div class="rd-scroll">
|
||||
<div v-if="loading" class="rd-status">Loading region dashboards…</div>
|
||||
<div v-else-if="loadFailed" class="rd-status rd-failed" role="status">
|
||||
Region data could not be loaded. This is a connection problem, not a filter.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="rd-subject">
|
||||
<h2>{{ subject.title }}</h2>
|
||||
<p>{{ subject.context }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rd-tiles">
|
||||
<div v-for="t in tiles" :key="t.label" class="rd-tile">
|
||||
<div class="rd-tile__value">{{ t.value }}</div>
|
||||
<div class="rd-tile__label">{{ t.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rd-chart">
|
||||
<BarChart
|
||||
:series="chartRows"
|
||||
:title="chartTitle"
|
||||
unit="people groups"
|
||||
color="#92b195"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="subject.kind === 'all'" class="rd-hint">
|
||||
Choose a region or a country on the Regions List tab to focus this dashboard.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
plugin-designs/regions-dashboard/regions-mfe.meta.json
Normal file
6
plugin-designs/regions-dashboard/regions-mfe.meta.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "nested-elements-mfe",
|
||||
"label": "Regions — List + Dashboards",
|
||||
"composes": ["regions-dashboard/regions-list", "regions-dashboard/region-dashboards"],
|
||||
"note": "The Driver's 2-tab Regions element. Composite MFE on the pane-wrapper layout, imitating my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (library/components/ViewSelect.vue) over Regions List at [0,0] and Region Dashboards at [1,0]. Clicking a region or country on tab 1 forwards the selection on 'doxa-regions-dashboard:select' and swaps to tab 2."
|
||||
}
|
||||
182
plugin-designs/regions-dashboard/regions-mfe.vue
Normal file
182
plugin-designs/regions-dashboard/regions-mfe.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<script setup>
|
||||
/**
|
||||
* regions-mfe.vue — the regions-dashboard NESTED-ELEMENTS MFE (the 2-tab Regions element).
|
||||
*
|
||||
* @description The composite the Driver asked for: ONE element carrying the SELECT VIEW switcher
|
||||
* over two panes — tab 1 "Regions List" (regions-list) and tab 2 "Region Dashboards"
|
||||
* (region-dashboards). Clicking a region or a country on tab 1 swaps to tab 2 with that
|
||||
* selection already applied.
|
||||
*
|
||||
* IMITATES upg-100-mfe.vue, does not reinvent it. That file is this repo's reference composite,
|
||||
* and the Driver named it explicitly as the thing to copy. Same shape, tab for tab:
|
||||
* • the pane-wrapper layout (buildPaneColumn + PANE_GRID_STYLES) supplies the scrolling column
|
||||
* • the SAME View switcher in the header — now the SHARED library component ViewSelect.vue,
|
||||
* which is that control extracted from upg-100-mfe rather than copied a third time
|
||||
* • ONE shared instanceId across both panes, so the cross-pane selection event resolves here
|
||||
* • paneHint:false, because the header switcher already says there is a second view
|
||||
* • INHERITED_PAGE_KEYS forwarded explicitly (the cap-4 asset-path lesson from upg-100-mfe:
|
||||
* a nested element has no leaf page of its own to hand it page-scoped config)
|
||||
*
|
||||
* THE TAB-1 -> TAB-2 WIRING. regions-list already dispatches 'region-selected' /
|
||||
* 'country-selected' as composed, bubbling CustomEvents — it was built to be nested. This
|
||||
* composite listens on its own stage, normalises the two payloads to { regionSlug, countrySlug },
|
||||
* re-broadcasts them on the scoped 'doxa-regions-dashboard:select' window event that tab 2
|
||||
* listens for, and scrolls to tab 2. That is the same seam upg-100-mfe uses for the poster
|
||||
* wall's 'show-on-map', and it keeps each pane under its own ProfileLoader/Pinia.
|
||||
*/
|
||||
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import ViewSelect from '@map/components/ViewSelect.vue'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
|
||||
const cfg = inject('profileConfig', ref(null))
|
||||
const uiStore = inject('uiStore', null)
|
||||
const isDark = computed(() => !!uiStore?.isDark)
|
||||
|
||||
const TAG = 'regions-dashboard'
|
||||
// One instanceId shared by BOTH panes, so the selection event scopes to THIS embed even when
|
||||
// two Regions elements sit on the same page.
|
||||
const instanceId = computed(() => cfg.value?.instanceId || 'regions-mfe')
|
||||
|
||||
const spec = computed(() => ({
|
||||
id: 'regions-mfe',
|
||||
tag: TAG,
|
||||
config: { profile: 'regions-list', instanceId: instanceId.value },
|
||||
panes: [
|
||||
{ id: 'list', label: 'Regions List', at: [0, 0], config: { profile: 'regions-list' } },
|
||||
{ id: 'dashboard', label: 'Region Dashboards', at: [1, 0], config: { profile: 'region-dashboards' } },
|
||||
],
|
||||
// The View switcher lives in this element's own header, so the layout's "… below ↓" pill
|
||||
// would be a second, redundant control (same call upg-100-mfe makes).
|
||||
paneHint: false,
|
||||
}))
|
||||
|
||||
const stage = ref(null)
|
||||
const view = ref('list')
|
||||
let column = null
|
||||
let offScroll = null
|
||||
|
||||
// PAGE-LEVEL config keys a pane INHERITS from the host page — see the BUG-UPG100MFE-ASSET-PATH
|
||||
// note in upg-100-mfe.vue: a cap-4 composite nests children in THIS document, so anything the
|
||||
// generator bakes once per page has to be forwarded explicitly or the child falls back to its
|
||||
// own default. Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
|
||||
// `profile` / `instanceId` cannot be overwritten by the host's — that is what makes a pane a
|
||||
// distinct view at all (pane.config is spread last and always wins).
|
||||
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa']
|
||||
|
||||
function paneElement(pane) {
|
||||
const el = document.createElement(TAG)
|
||||
const host = cfg.value || {}
|
||||
const inherited = {}
|
||||
for (const k of INHERITED_PAGE_KEYS) {
|
||||
if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
|
||||
}
|
||||
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
|
||||
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
|
||||
el.setAttribute('profile-config', JSON.stringify(pcfg))
|
||||
return el
|
||||
}
|
||||
|
||||
function go(id) {
|
||||
view.value = id
|
||||
column?.scrollTo(id)
|
||||
}
|
||||
|
||||
// ── tab 1 -> tab 2: forward the selection, then swap the visible pane ───────
|
||||
const SELECT_EVENT = 'doxa-regions-dashboard:select'
|
||||
|
||||
function forwardSelection(detail) {
|
||||
window.dispatchEvent(new CustomEvent(SELECT_EVENT, {
|
||||
detail: { ...detail, instanceId: instanceId.value },
|
||||
}))
|
||||
go('dashboard')
|
||||
}
|
||||
|
||||
// A region heading (or its "See X dashboard" link) was clicked on tab 1.
|
||||
function onRegionSelected(e) {
|
||||
const d = e?.detail || {}
|
||||
forwardSelection({ regionSlug: d.slug || '', countrySlug: '' })
|
||||
}
|
||||
|
||||
// A country row was clicked on tab 1 — carry BOTH slugs so tab 2 can show the country in the
|
||||
// context of its region rather than as one lone bar.
|
||||
function onCountrySelected(e) {
|
||||
const d = e?.detail || {}
|
||||
forwardSelection({ regionSlug: d.region?.slug || '', countrySlug: d.slug || '' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!stage.value) return
|
||||
column = buildPaneColumn(spec.value, { host: stage.value })
|
||||
if (!column) return
|
||||
for (const pane of column.panes) {
|
||||
if (!pane.config) continue
|
||||
column.mountEl(pane.id)?.appendChild(paneElement(pane))
|
||||
}
|
||||
// Tabs are a readout as well as a control — follow the user's scroll.
|
||||
offScroll = column.onScroll(() => {
|
||||
const cur = column.current()
|
||||
if (cur && cur !== view.value) view.value = cur
|
||||
})
|
||||
// regions-list's events are composed+bubbling, so they surface on this stage; listening HERE
|
||||
// rather than on window keeps two Regions embeds on one page from hearing each other.
|
||||
stage.value.addEventListener('region-selected', onRegionSelected)
|
||||
stage.value.addEventListener('country-selected', onCountrySelected)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stage.value?.removeEventListener('region-selected', onRegionSelected)
|
||||
stage.value?.removeEventListener('country-selected', onCountrySelected)
|
||||
offScroll?.()
|
||||
column?.destroy()
|
||||
column = null
|
||||
})
|
||||
|
||||
// Share the WHOLE MFE: this composite profile's own leaf page, TREE-RELATIVE.
|
||||
const SHARE_EMBED_PATH = 'regions-dashboard/regions-mfe/index.html'
|
||||
|
||||
// NO BACKTICKS inside this CSS string, including in comments — one terminates the JS template
|
||||
// literal and the build stops compiling (house style, AGENT-BRIEF.md).
|
||||
useShadowStyles(PANE_GRID_STYLES + '\n' +
|
||||
// SELF-SIZE. Every layer inside is absolute/height:100% (.rmfe -> .pane-col -> .pane), so the
|
||||
// chain resolves to ZERO unless the HOST itself is sized — the "elements are not embedding"
|
||||
// failure upg-100-mfe documents. Declaring :host makes this fill whatever slot it is mounted
|
||||
// into, in every deploy method.
|
||||
':host{ display:block; width:100%; height:100%; min-height:420px; }' +
|
||||
// Material LIGHT palette only — no dark mode, no prefers-color-scheme override.
|
||||
'.rmfe{ --ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;' +
|
||||
' --ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;' +
|
||||
' --header:56px;' +
|
||||
' position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
|
||||
' background:var(--ui-bg); color:var(--ui-text);' +
|
||||
' font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif; }' +
|
||||
// Header: same height/border as the shell; switcher centred, share at the right.
|
||||
'.rmfe .hdr{ flex:0 0 var(--header); height:var(--header); position:relative;' +
|
||||
' display:flex; align-items:center; justify-content:flex-end; padding:0 12px;' +
|
||||
' background:var(--ui-bg); border-bottom:1px solid var(--ui-border); }' +
|
||||
// POSITIONING is the caller's job — ViewSelect ships its own metrics but no placement, so the
|
||||
// absolute centring lives here exactly as it does in the shell and in upg-100-mfe.
|
||||
'.rmfe .hdr .viewsel{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }' +
|
||||
// Stage: the pane-wrapper column mounts here (its .pane-col is absolute).
|
||||
'.rmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }' +
|
||||
'.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }',
|
||||
'regions-mfe'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rmfe">
|
||||
<header class="hdr">
|
||||
<ViewSelect
|
||||
:views="spec.panes"
|
||||
:current="view"
|
||||
legend="Select View"
|
||||
label="Views"
|
||||
@select="go"
|
||||
/>
|
||||
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
|
||||
</header>
|
||||
<div class="stage" ref="stage"></div>
|
||||
</div>
|
||||
</template>
|
||||
168
public/doxa-maps/regions-dashboard/region-dashboards/index.html
generated
Normal file
168
public/doxa-maps/regions-dashboard/region-dashboards/index.html
generated
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>region-dashboards · regions-dashboard</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, regions-dashboard { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<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>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. The Driver is
|
||||
PHOTOSENSITIVE and a 60fps resize-strobe was already fixed this session; do NOT turn this into
|
||||
a per-frame or per-event handler. */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("regions-dashboard");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) { return mount(''); }
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
179
public/doxa-maps/regions-dashboard/regions-mfe/index.html
generated
Normal file
179
public/doxa-maps/regions-dashboard/regions-mfe/index.html
generated
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>regions-mfe · regions-dashboard</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
|
||||
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
|
||||
scrolls PAST one stacked section down to the next (BUG-DASHBOARD-CANT-SCROLL-DOWN). This is the
|
||||
OPPOSITE of the fixed-viewport map/single-element leaf below: NO position:fixed, NO inset:0,
|
||||
height:auto so the page can grow. See StackLayout.vue's "THE LAYOUT OWNS THE SCROLL" contract. */
|
||||
html { height: 100%; }
|
||||
html, body { margin: 0; background: #0a0e15; }
|
||||
body { min-height: 100%; overflow-y: auto; }
|
||||
/* MIN-HEIGHT IN VIEWPORT UNITS, NOT PERCENT (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT).
|
||||
min-height:100% here resolved against BODY's computed height, which is auto (body only has
|
||||
min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any
|
||||
element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane)
|
||||
then measured 0 and the leaf looked empty. 100dvh depends on NO ancestor, keeps the page
|
||||
growable (it is a MIN), and is correct on mobile with dynamic toolbars. */
|
||||
#slot, regions-dashboard { position: static; inset: auto; width: 100%; min-height: 100dvh; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<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>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-mfe","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. The Driver is
|
||||
PHOTOSENSITIVE and a 60fps resize-strobe was already fixed this session; do NOT turn this into
|
||||
a per-frame or per-event handler. */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("regions-dashboard");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) { return mount(''); }
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue