feat: migrate research dashboard into doxa-maps as app-profile research-dashboard (self-contained; reuses @map library; includes facet/rail collapse + table density fixes)
This commit is contained in:
parent
bd3c1eb2f5
commit
b4db24c54c
34 changed files with 3463 additions and 12 deletions
118
app-profiles/research-dashboard/dashboard.config.js
Normal file
118
app-profiles/research-dashboard/dashboard.config.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* dashboard.config.js — the ONE config object for the doxa-research-dashboard profile.
|
||||
*
|
||||
* This is the headline DOXA-LIB-2 proof: the FACET_ACC accessor map (formerly copy-pasted
|
||||
* into 9 files) is declared ONCE here, under `facets`, and read everywhere via
|
||||
* inject('dashboardConfig') + useDashboardShell. Columns / charts / facet-rail sections /
|
||||
* KPIs / detail fields are likewise data, not code — the library panels render them
|
||||
* generically. Values are copied faithfully from the original research-dashboard so the
|
||||
* migrated bundle behaves identically.
|
||||
*/
|
||||
import { lbl } from './src/composables/useDashboardAggregations.js'
|
||||
|
||||
// same numeric coercion the DataTable uses for its num columns
|
||||
const num = (v) => { if (typeof v === 'number') return v; const n = parseFloat(String(v ?? '').replace(/[^0-9.\-]/g, '')); return isNaN(n) ? null : n }
|
||||
|
||||
// affinity fields arrive on the RAW pray-tools row as {value,label} objects
|
||||
// (rop1 / imb_reg_of_people_2 / imb_reg_of_people_25 — the same objects DataSourceManager
|
||||
// splits into affinityBlock+affinityBlockLabel / cluster+clusterLabel / peopleGroup+peopleGroupLabel).
|
||||
// The People facet accessors return the stable CODE (value) for identity/filtering; the human
|
||||
// name is resolved in DashboardFacetRail via useAffinityBlockLegendData — the SAME resolver the
|
||||
// research-map's semantic tree uses — so bloc/cluster/PG labels can never drift from the map.
|
||||
const code = (v) => (v && typeof v === 'object' ? (v.value ?? v.label ?? '') : (v ?? ''))
|
||||
|
||||
export default {
|
||||
// ── identity ──
|
||||
id: 'doxa-research-dashboard',
|
||||
title: 'Research Dashboard',
|
||||
element: 'doxa-research-dashboard',
|
||||
|
||||
// ── data source ── (consumed by useDashboardData; the arg is currently advisory —
|
||||
// the store reads @map/config/sources.json activeSource = pray-tools, preserving behavior)
|
||||
data: {
|
||||
source: 'pray-tools',
|
||||
primaryKey: 'slug',
|
||||
},
|
||||
|
||||
// ── facet accessors ── THE single former-duplicated FACET_ACC, declared ONCE.
|
||||
facets: {
|
||||
religion: { label: 'Religion', get: (r) => lbl(r.religion) },
|
||||
wagf_region: { label: 'Region', get: (r) => lbl(r.wagf_region) },
|
||||
wagf_block: { label: 'Block', get: (r) => lbl(r.wagf_block) },
|
||||
country_code: { label: 'Country', get: (r) => lbl(r.country_code), geoIso: (r) => r.country_code && r.country_code.value },
|
||||
engagement_status: { label: 'Status', get: (r) => lbl(r.engagement_status) },
|
||||
language_family: { label: 'Language Family', get: (r) => lbl(r.imb_language_family) },
|
||||
primary_language: { label: 'Language', get: (r) => lbl(r.primary_language) },
|
||||
// People tier accessors — key by CODE (DashboardFacetRail resolves code→human name via
|
||||
// useAffinityBlockLegendData). Mirrors research-map's affinity-block → cluster → PG hierarchy.
|
||||
affinity_bloc: { label: 'Affinity Bloc', get: (r) => code(r.rop1) },
|
||||
cluster: { label: 'Cluster', get: (r) => code(r.imb_reg_of_people_2) },
|
||||
people_group: { label: 'People Group', get: (r) => code(r.imb_reg_of_people_25) },
|
||||
},
|
||||
|
||||
// ── facet rail sections ── (DashboardFacetRail) — ONE centralized semantic tree mirroring the
|
||||
// research-map's tree, top→bottom: People → Place → Language → Religion → Status.
|
||||
// `tree:'affinity'` tells the rail to resolve the People codes to human names via the shared
|
||||
// useAffinityBlockLegendData composable (single source of truth with the map's semantic tree).
|
||||
facetRail: [
|
||||
{ id: 'people', title: 'People', drill: ['affinity_bloc', 'cluster', 'people_group'], tree: 'affinity', topN: [30, 40, 60] },
|
||||
{ id: 'place', title: 'Place', drill: ['wagf_region', 'wagf_block', 'country_code'] },
|
||||
{ id: 'language', title: 'Language', drill: ['language_family', 'primary_language'] },
|
||||
{ id: 'religion', title: 'Religion', facet: 'religion', topN: 24 },
|
||||
{ id: 'status', title: 'Status', facet: 'engagement_status', topN: 16 },
|
||||
],
|
||||
|
||||
// ── KPI strip ── (computed by useDashboardShell; mirrors the original 6 stat computeds)
|
||||
kpis: [
|
||||
{ id: 'total', label: 'UPGs in scope', kind: 'count', showDelta: true },
|
||||
{ id: 'pop', label: 'Σ population', kind: 'sum', field: 'population' },
|
||||
{ id: 'unreached', label: '% unreached', kind: 'pct',
|
||||
test: (r) => !/engaged/i.test(lbl(r.engagement_status)) || /unengaged/i.test(lbl(r.engagement_status)) },
|
||||
{ id: 'countries', label: 'countries touched', kind: 'distinct', field: 'country_code', via: 'geoIso' },
|
||||
{ id: 'praying', label: 'Σ praying', kind: 'sum', field: 'people_praying' },
|
||||
{ id: 'adopted', label: 'Adopted %', kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 },
|
||||
],
|
||||
|
||||
// ── table columns ── (DashboardDataTable)
|
||||
columns: [
|
||||
{ key: 'name', label: 'People Group', get: (r) => r.name || '', type: 'str' },
|
||||
{ key: 'country', label: 'Country', get: (r) => lbl(r.country_code), type: 'str' },
|
||||
{ key: 'region', label: 'Region', get: (r) => lbl(r.wagf_region), type: 'str' },
|
||||
{ key: 'language', label: 'Language', get: (r) => lbl(r.primary_language), type: 'str' },
|
||||
{ key: 'religion', label: 'Religion', get: (r) => lbl(r.religion), type: 'str' },
|
||||
{ key: 'population', label: 'Population', get: (r) => num(r.population), type: 'num' },
|
||||
{ key: 'people_praying', label: 'Praying', get: (r) => num(r.people_praying), type: 'num' },
|
||||
{ key: 'engagement', label: 'Engagement', get: (r) => lbl(r.engagement_status), type: 'str' },
|
||||
],
|
||||
|
||||
// ── charts ── (DashboardChartsRail)
|
||||
charts: [
|
||||
{ id: 'religion', type: 'donut', facet: 'religion', 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 },
|
||||
],
|
||||
|
||||
// ── detail drawer ── fields surfaced for a focused record
|
||||
detail: {
|
||||
photo: (r) => r.has_photo && r.image_url,
|
||||
fields: ['religion', 'primary_language', 'people_praying', 'adopted_by_churches', 'engagement_status'],
|
||||
},
|
||||
|
||||
// ── search header ── (DOXA-LIB-3) the 4 facet search bars (DashboardSearchHeader).
|
||||
// Each bar = one standalone GeocoderComponent consuming useGeocoderSearch via a
|
||||
// busAdapter sink. `context` scopes the geocoder's result ordering; `kinds` is the
|
||||
// supportedKinds subset the composable accepts for that bar; `label` is the placeholder.
|
||||
// The bar's busAdapter maps the RESOLVED kind → a dashboard facet key (people-group
|
||||
// results go to selectionSet; the rest to filterSet) — the kind-switch itself stays in
|
||||
// the composable. See src/components/dashboards/README.md.
|
||||
search: [
|
||||
{ id: 'people', label: 'Search people', context: 'people-groups',
|
||||
kinds: ['affinity-bloc', 'cluster', 'people-group'] },
|
||||
{ id: 'place', label: 'Search place', context: 'regions',
|
||||
kinds: ['region', 'country'] },
|
||||
{ id: 'language', label: 'Search language', context: 'language',
|
||||
kinds: ['language-family', 'language', 'dialect'] },
|
||||
{ id: 'religion', label: 'Search religion', context: 'religion',
|
||||
kinds: ['religion'] },
|
||||
],
|
||||
}
|
||||
159
app-profiles/research-dashboard/index.html
Normal file
159
app-profiles/research-dashboard/index.html
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · research-dashboard staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
|
||||
<!-- Map in the center cell — Mapbox GL loaded globally, like doxa-research-map. -->
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.js"></script>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: #0b0f14; color: #e5e7eb; min-height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
padding: 10px 16px; background: #111827; border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
.topbar .spacer { flex: 1; }
|
||||
.profile-tab {
|
||||
padding: 8px 16px; background: transparent; border: 1px solid #334155; color: #94a3b8;
|
||||
font: 700 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: .06em; text-transform: uppercase; cursor: pointer;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.profile-tab:hover { border-color: #64748b; color: #e5e7eb; }
|
||||
.profile-tab.active { border-color: #60a5fa; color: #60a5fa; background: rgba(96,165,250,.08); }
|
||||
#showcase { display: flex; flex-direction: column; gap: 24px; padding: 20px; max-width: 1500px; margin: 0 auto; }
|
||||
.gem-frame {
|
||||
--fw: 14px; --gem: #39ff80; --gem-rgb: 57, 255, 128;
|
||||
position: relative; padding: var(--fw); background: #1a2420;
|
||||
border: 2px solid var(--gem);
|
||||
box-shadow: 0 0 20px rgba(var(--gem-rgb), .12), inset 0 0 20px rgba(var(--gem-rgb), .04);
|
||||
transition: box-shadow .3s; isolation: isolate;
|
||||
}
|
||||
.gem-frame-content { border: 2px solid var(--gem); overflow: hidden; background: #142118; }
|
||||
.gem-header { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: rgba(var(--gem-rgb), .04); border-bottom: 2px solid var(--gem); }
|
||||
.gem-header .label { font: 800 13px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .08em; color: #fff; }
|
||||
.gem-tag { display: inline-block; padding: 2px 8px; font: 10px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--gem); background: rgba(var(--gem-rgb), .08); border: 1px solid var(--gem); border-radius: 999px; }
|
||||
.gem-index { margin-left: auto; font: 10px/1 ui-monospace, monospace; color: #64748b; letter-spacing: .1em; }
|
||||
.gem-slot { position: relative; width: 100%; height: 760px; }
|
||||
research-dashboard-map { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
@media (max-width: 768px) {
|
||||
#showcase { padding: 8px; gap: 12px; }
|
||||
.gem-frame { --fw: 8px; }
|
||||
.gem-frame-content { border-width: 1px; }
|
||||
.gem-slot { height: calc(100vh - 120px); min-height: 620px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script>
|
||||
window.DOXA_TOKEN = (new URLSearchParams(location.search).get('tk')||(typeof window!=='undefined'&&window.MAPBOX_TOKEN)||'');
|
||||
window.DOXA_SLOTS = [
|
||||
{ profile: 'research-dashboard', label: 'Research Dashboard — map + dense panels', gem: 'amethyst',
|
||||
config: { instanceId: 'dev-dashboard-1' } },
|
||||
];
|
||||
</script>
|
||||
|
||||
<header class="topbar" id="topbar">
|
||||
<div class="spacer"></div>
|
||||
</header>
|
||||
|
||||
<div id="showcase"></div>
|
||||
|
||||
<script>
|
||||
const GEM_PALETTE = {
|
||||
amethyst: { name: 'amethyst', hex: '#9333ea' },
|
||||
sapphire: { name: 'sapphire', hex: '#2563eb' },
|
||||
aquamarine: { name: 'aquamarine', hex: '#0891b2' },
|
||||
emerald: { name: 'emerald', hex: '#059669' },
|
||||
topaz: { name: 'topaz', hex: '#d97706' },
|
||||
citrine: { name: 'citrine', hex: '#ca8a04' },
|
||||
tourmaline: { name: 'tourmaline', hex: '#db2777' },
|
||||
onyx: { name: 'onyx', hex: '#1f2937' }
|
||||
};
|
||||
const GEM_CYCLE = ['amethyst', 'sapphire', 'aquamarine', 'emerald', 'topaz', 'citrine', 'tourmaline'];
|
||||
function gemForIndex(i) { return GEM_PALETTE[GEM_CYCLE[((i % GEM_CYCLE.length) + GEM_CYCLE.length) % GEM_CYCLE.length]]; }
|
||||
function gemByName(n) { return GEM_PALETTE[n] || GEM_PALETTE.onyx; }
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace('#', '');
|
||||
const f = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
|
||||
return parseInt(f.slice(0, 2), 16) + ', ' + parseInt(f.slice(2, 4), 16) + ', ' + parseInt(f.slice(4, 6), 16);
|
||||
}
|
||||
|
||||
const TK = window.DOXA_TOKEN || '';
|
||||
const SLOTS = window.DOXA_SLOTS || [];
|
||||
const topbar = document.getElementById('topbar');
|
||||
const showcase = document.getElementById('showcase');
|
||||
const STORAGE_KEY = 'doxa-maps:research-dashboard:profile';
|
||||
|
||||
const PROFILES = [];
|
||||
SLOTS.forEach(s => { if (!PROFILES.includes(s.profile)) PROFILES.push(s.profile); });
|
||||
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
let activeProfile = PROFILES.includes(saved) ? saved : PROFILES[0];
|
||||
|
||||
function buildTabs() {
|
||||
Array.from(topbar.querySelectorAll('.profile-tab')).forEach(el => el.remove());
|
||||
const spacer = topbar.querySelector('.spacer');
|
||||
PROFILES.forEach(p => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'profile-tab' + (p === activeProfile ? ' active' : '');
|
||||
btn.textContent = p;
|
||||
btn.onclick = () => selectProfile(p);
|
||||
topbar.insertBefore(btn, spacer);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
showcase.innerHTML = '';
|
||||
const slotsForProfile = SLOTS.filter(s => s.profile === activeProfile);
|
||||
slotsForProfile.forEach((slot, i) => {
|
||||
const gem = slot.gem ? gemByName(slot.gem) : gemForIndex(i);
|
||||
const gemRgb = hexToRgb(gem.hex);
|
||||
const config = Object.assign({}, slot.config, { profile: slot.profile, tk: TK });
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'gem-frame';
|
||||
el.id = 'frame-' + (slot.config?.instanceId || `${slot.profile}-${i}`);
|
||||
el.style.setProperty('--gem', gem.hex);
|
||||
el.style.setProperty('--gem-rgb', gemRgb);
|
||||
el.innerHTML = `
|
||||
<div class="gem-frame-content">
|
||||
<div class="gem-header">
|
||||
<span class="label">${slot.label}</span>
|
||||
<span class="gem-tag">${gem.name}</span>
|
||||
<span class="gem-index">#${i + 1}</span>
|
||||
</div>
|
||||
<div class="gem-slot">
|
||||
<research-dashboard-map profile-config='${JSON.stringify(config)}'></research-dashboard-map>
|
||||
</div>
|
||||
</div>`;
|
||||
showcase.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function selectProfile(p) {
|
||||
if (p === activeProfile) return;
|
||||
activeProfile = p;
|
||||
localStorage.setItem(STORAGE_KEY, p);
|
||||
buildTabs();
|
||||
renderActive();
|
||||
}
|
||||
|
||||
buildTabs();
|
||||
renderActive();
|
||||
</script>
|
||||
|
||||
<script type="module" src="./index.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
66
app-profiles/research-dashboard/index.js
Normal file
66
app-profiles/research-dashboard/index.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* research-dashboard / index.js — Bundle entry (DOXA-LIB-2)
|
||||
*
|
||||
* Vite multi-entry build target. Emits app/research-dashboard.js.
|
||||
*
|
||||
* Mirrors app-profiles/doxa-research-map/index.js (same ProfileLoader plumbing).
|
||||
* Registers a SINGLE tag (UNCHANGED from the original profile — only the bundle folder
|
||||
* is now plural so it can host N dashboard profiles tomorrow):
|
||||
* - <research-dashboard-map> config-driven people-group research dashboard
|
||||
*
|
||||
* The dashboard mounts a Mapbox map in its center cell (Mapbox GL is loaded globally by
|
||||
* index.html and the token arrives via profile-config.tk), so the RTL-text plugin is
|
||||
* loaded once here, exactly like the map bundles.
|
||||
*
|
||||
* Profile registry — same Q3 contract as the map bundles: ProfileLoader lives in @map and
|
||||
* cannot import.meta.glob across the package boundary, so the bundle evaluates
|
||||
* import.meta.glob('./*.vue') HERE and hands the result to the loader via
|
||||
* app.provide('profileModules'). The library panels live in @map/components/dashboards and
|
||||
* are imported by the profile .vue — the glob only registers the profile screen(s).
|
||||
*/
|
||||
|
||||
import { defineCustomElement } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ProfileLoader from '@map/ProfileLoader.vue'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
|
||||
// ─── Bundle-private profile registry ─────────────────────────────────────────
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
||||
// ─── Mapbox RTL text plugin — load ONCE before any map instance ──────────────
|
||||
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
|
||||
try {
|
||||
window.mapboxgl.setRTLTextPlugin(
|
||||
'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
|
||||
null,
|
||||
true // lazy
|
||||
)
|
||||
} catch (_) {
|
||||
// Already set in this session — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh defineCustomElement-wrapped class. Each instance gets its own Pinia + i18n
|
||||
* so multiple embeds on the same page don't bleed state.
|
||||
*/
|
||||
function buildDoxaResearchDashboardElement() {
|
||||
return defineCustomElement(ProfileLoader, {
|
||||
configureApp(app) {
|
||||
app.use(createPinia())
|
||||
app.use(createAppI18n())
|
||||
app.provide('profileModules', profileModules)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const DoxaResearchDashboardElement = buildDoxaResearchDashboardElement()
|
||||
|
||||
// ─── Register ONE tag — <research-dashboard-map> ────────────────────────────
|
||||
if (typeof customElements !== 'undefined') {
|
||||
if (!customElements.get('research-dashboard-map')) {
|
||||
customElements.define('research-dashboard-map', DoxaResearchDashboardElement)
|
||||
}
|
||||
}
|
||||
|
||||
export default DoxaResearchDashboardElement
|
||||
222
app-profiles/research-dashboard/research-dashboard.vue
Normal file
222
app-profiles/research-dashboard/research-dashboard.vue
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<script setup>
|
||||
/**
|
||||
* @description Coordinated-Multiple-Views research dashboard for 10/40-window UPGs (live pray.doxa.life): docked facet rail + map-as-hero + linked table/charts, brushing-and-linking over one in-memory store.
|
||||
* @profile research-dashboard
|
||||
* @bundle research-dashboard
|
||||
* @element research-dashboard-map
|
||||
*
|
||||
* DOXA-LIB-2 thin profile: the page composition + grid CSS + a CONFIG import. All logic
|
||||
* (bus, store, FACET_ACC, filteredRows, KPI math, CSV, hash, responsive) lives in the shared
|
||||
* library via useDashboardShell(config); the panels come from @map/components/dashboards and
|
||||
* read their per-dashboard data from inject('dashboardConfig'). Behaviour is identical to the
|
||||
* original research-dashboard — only the duplication was hoisted into the library.
|
||||
*
|
||||
* DOXA-LIB-3: the 4-bar DashboardSearchHeader (Search people / place / language / religion)
|
||||
* sits between the topbar and the KPI strip. Each bar is a standalone GeocoderComponent
|
||||
* consuming the SAME useGeocoderSearch composable the map profiles use — via a busAdapter
|
||||
* sink that lands the resolved {kind,value} on the selection bus (bus.setFilter /
|
||||
* toggleSelect), so a pick narrows every linked panel. config.search drives the bars.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useDashboardShell, fmt } from './src/composables/useDashboardShell.js'
|
||||
import { useDashboardLayout } from './src/composables/useDashboardLayout.js'
|
||||
import {
|
||||
DashboardTopBar,
|
||||
DashboardSearchHeader,
|
||||
DashboardFacetRail,
|
||||
DashboardMapPanel,
|
||||
DashboardDataTable,
|
||||
DashboardChartsRail,
|
||||
DashboardDetailDrawer,
|
||||
} from './src/components/dashboards'
|
||||
import config from './dashboard.config.js'
|
||||
|
||||
// The assembler: creates+provides the bus/data/config, computes the linked surface.
|
||||
const { bus, data, kpis, exporter, responsive } = useDashboardShell(config)
|
||||
|
||||
// row-store flags for the template (auto-unwrapped in bindings)
|
||||
const rowCount = data.total
|
||||
const isLoading = data.loading
|
||||
const loadError = data.error
|
||||
|
||||
// responsive flags + collapsible dock + segmented mobile view (from the shell)
|
||||
const { shell, isNarrow, slimRail, slimFacet, mobileView, SEG, tableCollapsed, facetCollapsed, railCollapsed,
|
||||
setView, showFacets, showMap, showTable, showCharts } = responsive
|
||||
|
||||
// Drag-resize + persistence engine (desktop-only). Owns --facet-w / --rail-w / --table-h on the
|
||||
// shell root; restores the saved layout on mount, persists on drag-end, and reset() (fired by the
|
||||
// TopBar's "Align Dashboard" button via bus.emit('resetLayout')) snaps back to the exact defaults.
|
||||
// Defaults MUST match the CSS spine below (facet 248, rail 312, table 34%) so an untouched board is
|
||||
// pixel-identical and the responsive slim-rail classes keep working until the user drags.
|
||||
const { startFacetResize, startRailResize, startTableResize } = useDashboardLayout({
|
||||
shellRef: shell,
|
||||
bus,
|
||||
isNarrow,
|
||||
storageKey: 'rd-dashboard-layout',
|
||||
defaults: { facetW: 248, railW: 312, tableH: '34%' },
|
||||
})
|
||||
|
||||
// KPI strip — generic formatting per kind (mirrors the original inline strip).
|
||||
function kpiText(k) {
|
||||
if (k.kind === 'pct') return k.value
|
||||
if (k.kind === 'distinct') return k.value || '—'
|
||||
return fmt(k.value) // count + sum
|
||||
}
|
||||
|
||||
// sticky export bar — only when a selection exists.
|
||||
const selCount = computed(() => (bus.selectionSet ? bus.selectionSet.size : 0))
|
||||
function exportSelection() { exporter.exportCsv() }
|
||||
function clearSelection() { bus.clearSelection() }
|
||||
// Restore the collapsed table: the chevron lives inside the now-hidden table cell, so this
|
||||
// floating affordance re-opens it by emitting the same bus event the chevron uses (false = expand).
|
||||
function restoreTable() { bus.emit('toggleTable', false) }
|
||||
function toggleFacet() { bus.emit('toggleFacet', !facetCollapsed.value) }
|
||||
function toggleRail() { bus.emit('toggleRail', !railCollapsed.value) }
|
||||
function onBarClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-bar-export],[data-bar-clear]')
|
||||
if (!el) return
|
||||
if (el.hasAttribute('data-bar-export')) exportSelection(); else clearSelection()
|
||||
}
|
||||
|
||||
// The grid spine — injected into the shadow root (scoped <style> is dropped in the
|
||||
// defineCustomElement build). Verbatim from the original research-dashboard.
|
||||
useShadowStyles(`
|
||||
:host{ display:block; width:100%; height:100%; min-height:560px; box-sizing:border-box;
|
||||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px;
|
||||
--rd-accent:#e67e22; --rd-info:#2b6cb0; --rd-line:#e2e6ec; --rd-bg:#f7f9fb;
|
||||
--rd-ink:#1f2733; --rd-muted:#6b7686; --rd-panel:#ffffff;
|
||||
--facet-w:248px; --rail-w:312px;
|
||||
--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,248px) minmax(0,1fr) var(--rail-w,312px);
|
||||
grid-template-rows: 52px auto 42px 1fr var(--table-h,34%);
|
||||
grid-template-areas: "topbar topbar topbar" "search search search" "stats stats stats" "facets map rail" "facets table rail";
|
||||
gap:1px; background:var(--rd-line,#e2e6ec); container-type:inline-size;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
color:var(--rd-ink,#1f2733); }
|
||||
.rd-shell > [data-region="topbar"]{ grid-area:topbar; min-width:0; }
|
||||
.rd-shell > [data-region="search"]{ grid-area:search; min-width:0; }
|
||||
.rd-shell > [data-region="stats"]{ grid-area:stats; min-width:0; }
|
||||
.rd-shell > [data-region="facets"]{ grid-area:facets; min-width:0; min-height:0; }
|
||||
.rd-shell > [data-region="map"]{ grid-area:map; min-width:0; min-height:0; overflow:hidden; position:relative; }
|
||||
.rd-shell > [data-region="table"]{ grid-area:table; min-width:0; min-height:0; overflow:hidden; }
|
||||
.rd-shell > [data-region="rail"]{ grid-area:rail; min-width:0; min-height:0; }
|
||||
.rd-rail{ display:flex; flex-direction:column; min-height:0; height:100%; background:var(--rd-bg,#f7f9fb); }
|
||||
.rd-rail > [data-region="charts-rail"]{ flex:1 1 auto; min-height:0; }
|
||||
.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0; }
|
||||
/* 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,312px); }
|
||||
.rd-shell.rd-rail-collapsed{ grid-template-columns:var(--facet-w,248px) 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). */
|
||||
.rd-edge-caret{ position:absolute; top:50%; transform:translateY(-50%); z-index:6; width:16px;
|
||||
height:46px; display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
|
||||
border:1px solid #dde3ec; background:#fff; color:#3a4456; border-radius:5px; font-size:11px;
|
||||
line-height:1; box-shadow:0 1px 4px rgba(0,0,0,.14); }
|
||||
.rd-edge-caret:hover{ background:#eef2f7; }
|
||||
.rd-edge-facet{ left:5px; } .rd-edge-rail{ right:5px; }
|
||||
/* floating restore affordance — shown only while the table is collapsed (desktop). Pinned to
|
||||
the bottom edge of the center map cell so the map's 1fr can fill the whole center span. */
|
||||
.rd-table-restore{ position:absolute; left:50%; bottom:10px; transform:translateX(-50%); z-index:4;
|
||||
display:inline-flex; align-items:center; gap:6px; cursor:pointer; user-select:none;
|
||||
font-family:inherit; font-size:var(--fs-md,12px); font-weight:700; color:#fff;
|
||||
background:var(--rd-ink,#1f2733); border:1px solid #ffffff33; border-radius:var(--r-pill,999px);
|
||||
padding:5px 14px; box-shadow:0 2px 10px #0003; backdrop-filter:blur(4px); }
|
||||
.rd-table-restore:hover{ background:#000; color:var(--rd-accent,#e67e22); }
|
||||
.rd-shell.rd-slim-rail{ --rail-w:240px; }
|
||||
.rd-shell.rd-slim-facet{ --facet-w:196px; }
|
||||
/* Desktop drag-resize handles (useDashboardLayout). Thin, transparent until hover/active; each is
|
||||
a grid item pinned to the edge of its cell so no pixel offsets are needed. Straddle the 1px grid
|
||||
gap via a small negative margin for a comfortable hit target. Never rendered on narrow/mobile. */
|
||||
.rd-rz{ position:relative; z-index:6; background:transparent; touch-action:none; transition:background .15s; }
|
||||
.rd-rz::after{ content:''; position:absolute; background:transparent; border-radius:var(--r-pill,999px); transition:background .15s; }
|
||||
.rd-rz-facet{ grid-area:facets; justify-self:end; align-self:stretch; width:8px; margin-right:-4px; cursor:col-resize; }
|
||||
.rd-rz-rail{ grid-area:rail; justify-self:start; align-self:stretch; width:8px; margin-left:-4px; cursor:col-resize; }
|
||||
.rd-rz-table{ grid-area:map; align-self:end; justify-self:stretch; height:8px; margin-bottom:-4px; cursor:row-resize; }
|
||||
.rd-rz-facet::after, .rd-rz-rail::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:3px; height:34px; }
|
||||
.rd-rz-table::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:34px; height:3px; }
|
||||
.rd-rz:hover::after{ background:var(--rd-accent,#e67e22); }
|
||||
.rd-rz:active{ background:color-mix(in srgb, var(--rd-accent,#e67e22) 18%, transparent); }
|
||||
.rd-rz:active::after{ background:var(--rd-accent,#e67e22); }
|
||||
.rd-stats{ display:flex; align-items:center; gap:var(--s5,24px); height:100%; padding:0 var(--s4,16px);
|
||||
background:var(--rd-panel,#fff); overflow-x:auto; box-sizing:border-box; }
|
||||
.rd-stats .kpi{ display:flex; align-items:baseline; gap:6px; white-space:nowrap; }
|
||||
.rd-stats .kpi .n{ font-weight:800; font-size:15px; color:var(--rd-ink,#1f2733); transition:color .2s; }
|
||||
.rd-stats .kpi .l{ font-size:10px; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted,#6b7686); }
|
||||
.rd-stats .kpi .d{ color:var(--rd-accent,#e67e22); font-weight:700; font-size:11px; }
|
||||
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:52px auto 42px minmax(0,1fr) 46px;
|
||||
grid-template-areas:"topbar" "search" "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; }
|
||||
.rd-shell.rd-narrow > [data-region="search"]{ grid-area:search; }
|
||||
.rd-shell.rd-narrow > [data-region="stats"]{ grid-area:stats; }
|
||||
.rd-seg{ grid-area:seg; display:none; }
|
||||
.rd-shell.rd-narrow .rd-seg{ display:flex; }
|
||||
.rd-seg button{ flex:1; border:0; background:#fff; border-top:1px solid var(--rd-line,#e2e6ec); font-size:12px; font-weight:700; color:#5a6577; cursor:pointer; }
|
||||
.rd-seg button.on{ color:var(--rd-ink,#1f2733); box-shadow:inset 0 -3px 0 var(--rd-accent,#e67e22); }
|
||||
.rd-exportbar{ position:absolute; left:0; right:0; bottom:0; display:flex; align-items:center; gap:10px; padding:8px 14px;
|
||||
background:var(--rd-ink,#1f2733); color:#fff; z-index:50; box-shadow:0 -2px 10px #0003; animation:rd-slideup .2s ease; }
|
||||
.rd-exportbar .sp{ flex:1; } .rd-exportbar b{ font-size:13px; }
|
||||
.rd-exportbar button{ border:0; border-radius:6px; padding:6px 14px; font-weight:700; font-size:12px; cursor:pointer; }
|
||||
.rd-exportbar .x-go{ background:var(--rd-accent,#e67e22); color:#fff; } .rd-exportbar .x-clr{ background:#ffffff22; color:#fff; }
|
||||
@keyframes rd-slideup{ from{ transform:translateY(100%); } to{ transform:translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce){ .rd-shell *{ transition:none !important; animation:none !important; } }
|
||||
`, 'rd-shell-layout')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="shell" class="rd-shell"
|
||||
:class="{ 'rd-narrow': isNarrow, 'rd-slim-rail': slimRail, 'rd-slim-facet': slimFacet, 'rd-table-collapsed': tableCollapsed, 'rd-facet-collapsed': facetCollapsed, 'rd-rail-collapsed': railCollapsed }"
|
||||
:data-row-count="rowCount" :data-loading="isLoading ? '1' : '0'" :data-error="loadError || ''" :data-narrow="isNarrow ? '1' : '0'">
|
||||
<DashboardTopBar />
|
||||
<DashboardSearchHeader />
|
||||
<div class="rd-stats" data-region="stats" data-sub="stat-strip">
|
||||
<div class="kpi" v-for="k in kpis" :key="k.id">
|
||||
<span class="n" :data-kpi="k.id">{{ k.kind === 'count' && isLoading ? '…' : kpiText(k) }}</span>
|
||||
<span class="l">{{ k.label }}</span>
|
||||
<span v-if="k.delta" class="d" data-kpi-delta>↓{{ fmt(k.delta) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardFacetRail v-show="showFacets" />
|
||||
<div class="rd-cell-map" data-region="map" v-show="showMap">
|
||||
<DashboardMapPanel />
|
||||
<button v-if="tableCollapsed && !isNarrow" class="rd-table-restore" data-table-restore
|
||||
type="button" title="Show the people-groups table" @click="restoreTable">▴ Show table</button>
|
||||
<button v-if="!isNarrow" class="rd-edge-caret rd-edge-facet" type="button" data-toggle-facet
|
||||
:title="facetCollapsed ? 'Show filters' : 'Hide filters — grow the map'"
|
||||
@click="toggleFacet">{{ facetCollapsed ? '▸' : '◂' }}</button>
|
||||
<button v-if="!isNarrow" class="rd-edge-caret rd-edge-rail" type="button" data-toggle-rail
|
||||
:title="railCollapsed ? 'Show charts' : 'Hide charts — grow the map'"
|
||||
@click="toggleRail">{{ railCollapsed ? '◂' : '▸' }}</button>
|
||||
</div>
|
||||
<div class="rd-cell-table" data-region="table" v-show="showTable && (isNarrow || !tableCollapsed)"><DashboardDataTable /></div>
|
||||
<aside class="rd-rail" data-region="rail" v-show="showCharts">
|
||||
<DashboardDetailDrawer />
|
||||
<DashboardChartsRail />
|
||||
</aside>
|
||||
<!-- Desktop drag-resize handles — never on narrow/mobile; the map/table divider also hides
|
||||
while the table is collapsed (nothing to resize). @pointerdown starts the drag. -->
|
||||
<div v-show="!isNarrow && !facetCollapsed" class="rd-rz rd-rz-facet" data-resize="facet"
|
||||
role="separator" aria-orientation="vertical" title="Drag to resize the filters rail"
|
||||
@pointerdown="startFacetResize"></div>
|
||||
<div v-show="!isNarrow && !railCollapsed" class="rd-rz rd-rz-rail" data-resize="rail"
|
||||
role="separator" aria-orientation="vertical" title="Drag to resize the charts rail"
|
||||
@pointerdown="startRailResize"></div>
|
||||
<div v-show="!isNarrow && !tableCollapsed" class="rd-rz rd-rz-table" data-resize="table"
|
||||
role="separator" aria-orientation="horizontal" title="Drag to resize the table"
|
||||
@pointerdown="startTableResize"></div>
|
||||
<div class="rd-seg" data-segmented @click="setView">
|
||||
<button v-for="s in SEG" :key="s.k" :class="{ on: mobileView === s.k }" data-view-btn :data-view="s.k">{{ s.l }}</button>
|
||||
</div>
|
||||
<div v-if="selCount" class="rd-exportbar" data-export-bar @click="onBarClick">
|
||||
<b>{{ selCount }} selected</b><span class="sp"></span>
|
||||
<button class="x-clr" data-bar-clear>Clear</button>
|
||||
<button class="x-go" data-bar-export>Export CSV ↓</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<script setup>
|
||||
/**
|
||||
* Breadcrumb — RDASH phase 8 delighter: drill-down breadcrumb (World ▸ Asia ▸ Hinduism …).
|
||||
* Every filter pushes a crumb; click a crumb to pop back to that altitude (legible + reversible
|
||||
* linked state). Reads filterSet; click delegation (defineCustomElement-safe).
|
||||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
||||
const bus = inject('selectionBus', null)
|
||||
const filterSet = bus?.filterSet || {}
|
||||
const crumbs = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, v: String(v) })))
|
||||
|
||||
function popTo(k) {
|
||||
const keys = Object.keys(filterSet); const i = keys.indexOf(k)
|
||||
if (i < 0 || !bus) return
|
||||
keys.slice(i).forEach((kk) => bus.setFilter(kk, null))
|
||||
}
|
||||
function onClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-crumb]')
|
||||
if (!el || !bus) return
|
||||
const k = el.getAttribute('data-crumb')
|
||||
if (k === '__root__') bus.clearFilters(); else popTo(k)
|
||||
}
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-breadcrumb{ display:flex; align-items:center; gap:4px; font-size:11px; color:#cdd3df; overflow:hidden; }
|
||||
.rd-crumb{ cursor:pointer; padding:2px 6px; border-radius:4px; white-space:nowrap; }
|
||||
.rd-crumb:hover{ background:#ffffff1f; color:#fff; }
|
||||
.rd-crumb.root{ color:#9aa4b8; } .rd-crumb.cur{ color:#fff; font-weight:700; }
|
||||
.rd-sep{ opacity:.45; }
|
||||
`, 'rd-breadcrumb')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="rd-breadcrumb" data-breadcrumb @click="onClick" aria-label="Filter breadcrumb">
|
||||
<span class="rd-crumb root" data-crumb="__root__">World</span>
|
||||
<template v-for="(c, i) in crumbs" :key="c.k">
|
||||
<span class="rd-sep">▸</span>
|
||||
<span class="rd-crumb" :class="{ cur: i === crumbs.length - 1 }" data-crumb-item :data-crumb="c.k">{{ c.v }}</span>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<script setup>
|
||||
/**
|
||||
* ChartsRail — RDASH phase 5: cross-filtered charts rail. Religion donut + Region donut (doxa-region
|
||||
* palette) + Top-country bar, all re-aggregating on filterSet and brushable back into it (click a
|
||||
* slice/bar -> setFilter -> filter chip -> table+charts narrow). Hand-rolled SVG (0-dep, shadow-safe),
|
||||
* click via delegation on the stable container (per-element @click is unreliable in a virtualized/
|
||||
* patched defineCustomElement tree). Tweens via CSS (bar width + slice opacity).
|
||||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { aggregate, buildDonut, buildBars, lbl } from '../../composables/useDashboardAggregations.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
const filterSet = bus?.filterSet || {}
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
// Crossfilter rule: aggregate each chart over rows filtered by all OTHER active facets EXCEPT its
|
||||
// own — so filtering a chart by its own facet does NOT collapse it to a single slice. Mirrors the
|
||||
// `rowsExcluding([facet])` pattern in DashboardFacetRail.vue.
|
||||
function rowsExcluding(exclude) {
|
||||
const rows = dash?.rows?.value || []
|
||||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
if (!keys.length) return rows
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
}
|
||||
|
||||
const charts = computed(() => (cfg.charts || []).map((c) => {
|
||||
const acc = cfg.facets?.[c.facet]?.get || ((r) => lbl(r[c.facet]))
|
||||
// Exclude this chart's own facet so it keeps ALL its slices while highlighting the active one.
|
||||
const agg = aggregate(rowsExcluding([c.facet]), acc, c.topN)
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
facet: c.facet,
|
||||
title: c.title,
|
||||
geometry: c.type === 'bar' ? buildBars(agg) : buildDonut(agg),
|
||||
}
|
||||
}))
|
||||
const isActive = (facet, key) => filterSet[facet] === key
|
||||
// true when this chart's own facet has an active selection → drives the pop-out / dim-others state.
|
||||
const hasSelection = (facet) => filterSet[facet] != null
|
||||
|
||||
function pick(facet, key) {
|
||||
if (!key || key === 'Other' || !bus) return
|
||||
bus.setFilter(facet, filterSet[facet] === key ? null : key)
|
||||
}
|
||||
function onChartClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-facet]')
|
||||
if (el) pick(el.getAttribute('data-facet'), el.getAttribute('data-key'))
|
||||
}
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-charts{ display:flex; flex-direction:column; gap:var(--s3,12px); height:100%; min-height:0; padding:var(--s3,12px);
|
||||
background:var(--rd-panel,#fff); overflow:auto; box-sizing:border-box;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
||||
.rd-charts .rd-h{ font-weight:700; font-size:var(--fs-md,12px); text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted,#6b7686); }
|
||||
.rd-chart{ border:1px solid var(--rd-line,#e2e6ec); border-radius:var(--r-lg,10px); background:var(--rd-bg,#f7f9fb); padding:10px; }
|
||||
.rd-chart .rd-ct{ font-weight:700; font-size:var(--fs-sm,11px); color:var(--rd-ink,#1f2733); margin-bottom:6px; }
|
||||
.rd-donutwrap{ display:flex; align-items:center; gap:10px; }
|
||||
.rd-donut{ width:108px; height:108px; flex:0 0 auto; }
|
||||
.rd-slice{ cursor:pointer; transition:opacity .18s, transform .18s; transform-box:view-box; transform-origin:center; }
|
||||
.rd-slice:hover{ opacity:.82; }
|
||||
/* Active slice pops out (scale) with a clear ink stroke; when a slice is selected the rest dim. */
|
||||
.rd-donut.has-sel .rd-slice{ opacity:.42; }
|
||||
.rd-slice.on, .rd-donut.has-sel .rd-slice.on{ opacity:1; stroke:var(--rd-ink,#1f2733); stroke-width:2.5;
|
||||
transform:scale(1.08); filter:drop-shadow(0 1px 2px rgba(31,39,51,.35)); }
|
||||
.rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:#3a4456; max-height:108px; overflow:auto; flex:1 1 auto; }
|
||||
.rd-li{ display:flex; align-items:center; gap:5px; cursor:pointer; white-space:nowrap; padding:1px 2px; border-radius:3px; }
|
||||
.rd-li:hover{ background:#eef2f8; } .rd-li.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink,#1f2733); }
|
||||
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; } .rd-li b{ margin-left:auto; color:var(--rd-muted,#6b7686); font-variant-numeric:tabular-nums; }
|
||||
.rd-bars{ display:flex; flex-direction:column; gap:3px; }
|
||||
.rd-bar{ display:flex; align-items:center; gap:6px; cursor:pointer; font-size:10.5px; padding:1px 2px; border-radius:3px; }
|
||||
.rd-bar:hover{ background:#eef2f8; } .rd-bar.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink,#1f2733); }
|
||||
.rd-bl{ flex:0 0 84px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#3a4456; }
|
||||
.rd-bt{ flex:1 1 auto; height:11px; background:#eef1f6; border-radius:3px; overflow:hidden; }
|
||||
.rd-bf{ display:block; height:100%; border-radius:3px; transition:width .35s cubic-bezier(.4,0,.2,1); }
|
||||
.rd-bv{ flex:0 0 auto; color:var(--rd-muted,#6b7686); font-variant-numeric:tabular-nums; min-width:30px; text-align:right; }
|
||||
`, 'rd-charts')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rd-charts" data-region="charts-rail" @click="onChartClick">
|
||||
<div class="rd-h">Charts</div>
|
||||
|
||||
<template v-for="chart in charts" :key="chart.id">
|
||||
<div v-if="chart.type === 'donut'" class="rd-chart" :data-chart="chart.facet">
|
||||
<div class="rd-ct">{{ chart.title }}</div>
|
||||
<div class="rd-donutwrap">
|
||||
<svg class="rd-donut" :class="{ 'has-sel': hasSelection(chart.facet) }" viewBox="0 0 132 132" role="img" :aria-label="chart.title + ' donut'">
|
||||
<path v-for="s in chart.geometry" :key="s.key" :d="s.path" :fill="s.color" class="rd-slice"
|
||||
:class="{ on: isActive(chart.facet, s.key) }" :data-facet="chart.facet" :data-key="s.key">
|
||||
<title>{{ s.key }}: {{ s.value }} ({{ s.pct }}%)</title>
|
||||
</path>
|
||||
</svg>
|
||||
<div class="rd-legend">
|
||||
<span v-for="s in chart.geometry" :key="s.key" class="rd-li" :class="{ on: isActive(chart.facet, s.key) }"
|
||||
:data-facet="chart.facet" :data-key="s.key"><i :style="{ background: s.color }"></i>{{ s.key }}<b>{{ s.value }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rd-chart" :data-chart="chart.facet">
|
||||
<div class="rd-ct">{{ chart.title }}</div>
|
||||
<div class="rd-bars">
|
||||
<div v-for="b in chart.geometry" :key="b.key" class="rd-bar" :class="{ on: isActive(chart.facet, b.key) }"
|
||||
:data-facet="chart.facet" :data-key="b.key">
|
||||
<span class="rd-bl">{{ b.key }}</span>
|
||||
<span class="rd-bt"><span class="rd-bf" :style="{ width: (b.frac * 100) + '%', background: b.color }"></span></span>
|
||||
<span class="rd-bv">{{ b.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
<script setup>
|
||||
/**
|
||||
* DataTable — RDASH phase 3: Excel-style virtualized table bound to filterSet, multi-sort,
|
||||
* row multi-select writing to selectionSet.
|
||||
*
|
||||
* HAND-ROLLED (no AG Grid): the starter-kit node_modules is a symlink into the frozen doxa-life
|
||||
* tree, so `npm install ag-grid-community` would write to a read-only source AND breaks the
|
||||
* no-install guardrail. The plan's own §LIBRARIES names a headless/zero-CSS table as the
|
||||
* persecution-safe escape hatch — this is that: pure Vue + windowed virtualization for the 2,106
|
||||
* rows, own CSS via useShadowStyles (shadow-DOM safe), ARIA grid/sort roles, footer aggregation,
|
||||
* 'N selected' pill. Same functional contract as AG Grid for the verify (sort row-order + aria-sort,
|
||||
* checkbox -> selectionSet).
|
||||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
|
||||
const lbl = (v) => (v === null || v === undefined ? '' : (typeof v === 'object' ? (v.label ?? v.value ?? '') : v))
|
||||
const num = (v) => { if (typeof v === 'number') return v; const n = parseFloat(String(v ?? '').replace(/[^0-9.\-]/g, '')); return isNaN(n) ? null : n }
|
||||
const fmt = (n) => (n === null || n === undefined ? '' : Number(n).toLocaleString('en-US'))
|
||||
|
||||
const COLUMNS = computed(() => cfg.columns || [])
|
||||
// facet-key -> row accessor, so a filterSet entry narrows the table (phases 5/6 populate filterSet).
|
||||
const FACETS = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
|
||||
const allRows = computed(() => (dash?.rows?.value) || [])
|
||||
const filterSet = bus?.filterSet || {}
|
||||
const selectionSet = bus?.selectionSet
|
||||
|
||||
function matchFacet(r, key, val) {
|
||||
const acc = FACETS[key]; if (!acc) return true
|
||||
const rv = acc(r)
|
||||
return Array.isArray(val) ? val.includes(rv) : rv === val
|
||||
}
|
||||
const filtered = computed(() => {
|
||||
const keys = Object.keys(filterSet)
|
||||
if (!keys.length) return allRows.value
|
||||
return allRows.value.filter((r) => keys.every((k) => matchFacet(r, k, filterSet[k])))
|
||||
})
|
||||
|
||||
// ── multi-sort (shift-click stacks; per-column 3-state none->asc->desc->none) ──
|
||||
const sortState = ref([]) // [{ key, dir:'asc'|'desc' }]
|
||||
function onSort(col, ev) {
|
||||
const shift = !!(ev && ev.shiftKey)
|
||||
const i = sortState.value.findIndex((s) => s.key === col.key)
|
||||
if (i < 0) { sortState.value = (shift ? [...sortState.value] : []).concat({ key: col.key, dir: 'asc' }) }
|
||||
else if (sortState.value[i].dir === 'asc') { const ns = [...sortState.value]; ns[i] = { key: col.key, dir: 'desc' }; sortState.value = ns }
|
||||
else { sortState.value = shift ? sortState.value.filter((s) => s.key !== col.key) : [] }
|
||||
}
|
||||
function ariaSort(col) { const s = sortState.value.find((x) => x.key === col.key); return s ? (s.dir === 'asc' ? 'ascending' : 'descending') : 'none' }
|
||||
const sorted = computed(() => {
|
||||
const ss = sortState.value; if (!ss.length) return filtered.value
|
||||
const plan = ss.map((s) => ({ col: COLUMNS.value.find((c) => c.key === s.key), dir: s.dir === 'asc' ? 1 : -1 }))
|
||||
return filtered.value.slice().sort((a, b) => {
|
||||
for (const { col, dir } of plan) {
|
||||
let av = col.get(a), bv = col.get(b)
|
||||
if (av === null || av === undefined || av === '') av = col.type === 'num' ? -Infinity : ''
|
||||
if (bv === null || bv === undefined || bv === '') bv = col.type === 'num' ? -Infinity : ''
|
||||
if (av < bv) return -1 * dir; if (av > bv) return 1 * dir
|
||||
}
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
// ── windowed virtualization (2,106 rows; render only the visible slice) ──
|
||||
const ROW_H = 26
|
||||
const scrollTop = ref(0)
|
||||
const viewH = ref(360)
|
||||
const scroller = ref(null)
|
||||
let ro = null
|
||||
function onScroll(e) { scrollTop.value = e.target.scrollTop }
|
||||
const start = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - 6))
|
||||
const visCount = computed(() => Math.ceil(viewH.value / ROW_H) + 12)
|
||||
const windowRows = computed(() => sorted.value.slice(start.value, start.value + visCount.value))
|
||||
const padTop = computed(() => start.value * ROW_H)
|
||||
const padBot = computed(() => Math.max(0, (sorted.value.length - (start.value + windowRows.value.length)) * ROW_H))
|
||||
onMounted(() => {
|
||||
if (scroller.value) {
|
||||
viewH.value = scroller.value.clientHeight || 360
|
||||
ro = new ResizeObserver(() => { viewH.value = scroller.value.clientHeight || viewH.value })
|
||||
ro.observe(scroller.value)
|
||||
}
|
||||
// keep the chevron glyph in sync when the table is toggled from OUTSIDE this component
|
||||
// (e.g. the profile's floating "▴ Show table" restore control emits toggleTable=false).
|
||||
if (bus) bus.on('toggleTable', (v) => { collapsed.value = !!v })
|
||||
// a map pin click (or other panel) selects a PG -> scroll the virtualized table to it + highlight.
|
||||
if (bus) bus.on('focusRow', ({ slug } = {}) => {
|
||||
if (!slug || !scroller.value) return
|
||||
const idx = sorted.value.findIndex((r) => r.slug === slug)
|
||||
if (idx >= 0) scroller.value.scrollTop = Math.max(0, idx * ROW_H - (viewH.value / 2))
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => ro && ro.disconnect())
|
||||
|
||||
// ── selection (writes to selectionSet via the bus) ──
|
||||
const selCount = computed(() => (selectionSet ? selectionSet.size : 0))
|
||||
const isSel = (r) => !!(selectionSet && selectionSet.has(r.slug))
|
||||
// Event delegation on the stable scroll container: per-row @click is unreliable in a
|
||||
// defineCustomElement bundle when the v-for is virtualized (frequently patched). The container
|
||||
// receives the bubbled click; resolve the clicked row via data-slug, toggle via the bus.
|
||||
function onGridClick(e) {
|
||||
const rowEl = e.target && e.target.closest && e.target.closest('.rd-row')
|
||||
if (rowEl) { const slug = rowEl.getAttribute('data-slug'); if (slug && bus) bus.toggleSelect(slug) }
|
||||
}
|
||||
const allVisibleSelected = computed(() => filtered.value.length > 0 && filtered.value.every((r) => isSel(r)))
|
||||
function toggleAll() {
|
||||
if (!bus) return
|
||||
if (allVisibleSelected.value) filtered.value.forEach((r) => selectionSet.has(r.slug) && bus.toggleSelect(r.slug))
|
||||
else filtered.value.forEach((r) => !selectionSet.has(r.slug) && bus.toggleSelect(r.slug))
|
||||
}
|
||||
function clearSel() { bus && bus.clearSelection() }
|
||||
|
||||
// ── collapsible bottom dock (P5): chevron emits to the root, which shrinks the table grid row so the
|
||||
// map grows. Root is the source of truth for the grid; we mirror the state locally for the chevron. ──
|
||||
const collapsed = ref(false)
|
||||
function toggleDock() { collapsed.value = !collapsed.value; bus && bus.emit('toggleTable', collapsed.value) }
|
||||
|
||||
// ── footer aggregation ──
|
||||
const sumPop = computed(() => filtered.value.reduce((s, r) => s + (num(r.population) || 0), 0))
|
||||
const rowCount = computed(() => filtered.value.length)
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-table-wrap{ display:flex; flex-direction:column; height:100%; min-height:0; background:#fff; font-size:12px; }
|
||||
.rd-table-bar{ display:flex; align-items:center; gap:10px; padding:4px 10px; border-bottom:1px solid #dde3ec; flex:0 0 auto; }
|
||||
.rd-table-bar .ttl{ font-weight:700; color:var(--rd-ink,#1f2733); } .rd-table-bar .ct{ color:#7a8494; }
|
||||
.rd-dock{ all:unset; cursor:pointer; flex:0 0 auto; width:20px; height:20px; line-height:20px; text-align:center; border-radius:4px;
|
||||
color:var(--rd-muted,#6b7686); font-size:12px; }
|
||||
.rd-dock:hover{ background:var(--rd-bg,#f7f9fb); color:var(--rd-accent,#e67e22); }
|
||||
.rd-pill{ margin-left:auto; display:inline-flex; align-items:center; gap:6px; font-weight:700; font-size:11px; padding:3px 9px; border-radius:999px;
|
||||
background:#eef4ff; color:#2456c7; border:1px solid #cfe0ff; }
|
||||
.rd-pill button{ all:unset; cursor:pointer; color:#2456c7; opacity:.7; } .rd-pill button:hover{ opacity:1; }
|
||||
.rd-grid{ flex:1 1 auto; min-height:0; overflow:auto; position:relative; }
|
||||
.rd-thead{ position:sticky; top:0; z-index:2; display:flex; background:#f4f6fa; border-bottom:1px solid #dde3ec; }
|
||||
.rd-th{ flex:1 1 0; min-width:0; padding:3px 8px; font-weight:700; color:#3a4456; cursor:pointer; user-select:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.rd-th.num{ text-align:right; } .rd-th.chk{ flex:0 0 30px; cursor:default; text-align:center; padding:6px 0; }
|
||||
.rd-th .ind{ font-size:9px; color:#2456c7; margin-left:3px; }
|
||||
.rd-row{ display:flex; height:26px; line-height:16px; border-bottom:1px solid #f0f2f6; align-items:center; }
|
||||
.rd-row:hover{ background:#f8fafd; } .rd-row.sel{ background:#fff7ec; box-shadow:inset 3px 0 0 #e67e22; }
|
||||
.rd-td{ flex:1 1 0; min-width:0; padding:0 8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#2a3240; }
|
||||
.rd-td.num{ text-align:right; font-variant-numeric:tabular-nums; } .rd-td.chk{ flex:0 0 30px; text-align:center; padding:0; }
|
||||
.rd-foot{ flex:0 0 auto; display:flex; border-top:2px solid #dde3ec; background:#f4f6fa; font-weight:700; height:28px; align-items:center; color:#3a4456; }
|
||||
.rd-foot .rd-td{ color:#3a4456; }
|
||||
`, 'rd-datatable')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-table-wrap" :data-sel-count="selCount">
|
||||
<div class="rd-table-bar">
|
||||
<button class="rd-dock" :title="collapsed ? 'Expand table' : 'Collapse table — grow the map'"
|
||||
data-table-dock :aria-expanded="collapsed ? 'false' : 'true'" @click="toggleDock">{{ collapsed ? '▴' : '▾' }}</button>
|
||||
<span class="ttl">People Groups</span>
|
||||
<span class="ct" data-table-count>{{ fmt(rowCount) }} rows</span>
|
||||
<span v-if="selCount" class="rd-pill" data-sel-pill>
|
||||
<span>{{ selCount }} selected</span>
|
||||
<button title="Clear selection" @click="clearSel">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref="scroller" class="rd-grid" role="grid" :aria-rowcount="rowCount + 1" @scroll="onScroll" @click="onGridClick">
|
||||
<div class="rd-thead" role="row">
|
||||
<div class="rd-th chk" style="cursor:pointer" @click="toggleAll">
|
||||
<input type="checkbox" :checked="allVisibleSelected" aria-label="Select all" tabindex="-1" style="pointer-events:none" />
|
||||
</div>
|
||||
<div v-for="col in COLUMNS" :key="col.key" class="rd-th" :class="{ num: col.type === 'num' }"
|
||||
role="columnheader" :aria-sort="ariaSort(col)" :data-col="col.key" @click="onSort(col, $event)">
|
||||
{{ col.label }}<span class="ind">{{ ariaSort(col) === 'ascending' ? '▲' : ariaSort(col) === 'descending' ? '▼' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ height: padTop + 'px' }"></div>
|
||||
<div v-for="r in windowRows" :key="r.slug" class="rd-row" :class="{ sel: isSel(r) }" role="row"
|
||||
:data-slug="r.slug" :aria-selected="isSel(r) ? 'true' : 'false'">
|
||||
<div class="rd-td chk" role="gridcell">
|
||||
<input type="checkbox" class="rd-rowchk" :checked="isSel(r)" :aria-label="'Select ' + r.name" tabindex="-1" style="pointer-events:none" />
|
||||
</div>
|
||||
<div v-for="col in COLUMNS" :key="col.key" class="rd-td" :class="{ num: col.type === 'num' }" role="gridcell">
|
||||
{{ col.type === 'num' ? fmt(col.get(r)) : col.get(r) }}
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ height: padBot + 'px' }"></div>
|
||||
</div>
|
||||
|
||||
<div class="rd-foot" role="row">
|
||||
<div class="rd-td chk"></div>
|
||||
<div class="rd-td">Σ {{ fmt(rowCount) }} groups</div>
|
||||
<div class="rd-td"></div><div class="rd-td"></div><div class="rd-td"></div><div class="rd-td"></div>
|
||||
<div class="rd-td num">{{ fmt(sumPop) }}</div>
|
||||
<div class="rd-td"></div><div class="rd-td"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<script setup>
|
||||
/**
|
||||
* DetailDrawer — RDASH P3: docked detail panel at the TOP of the right rail (above ChartsRail).
|
||||
* Surfaces the rich, otherwise-unused fields of a single people group (thumbnail, religion,
|
||||
* language family, praying/committed/adopted counts, engagement) the moment a row is focused
|
||||
* (bus 'focusRow') or selected (bus 'select'). Stays compact when nothing is active so the map
|
||||
* keeps its space. All CSS via useShadowStyles (scoped <style> is dropped in the defineCustomElement
|
||||
* shadow-DOM build); tokens only, 1px --rd-line border, no shadow. image_url is runtime API data,
|
||||
* not a baked asset — safe to render in an <img>.
|
||||
*
|
||||
* Config-driven (DOXA-LIB-2): the per-dashboard constants (FACET_ACC for the CSV exporter and the
|
||||
* photo test) are read from inject('dashboardConfig') instead of being hard-coded. Rendering and
|
||||
* behavior are unchanged from the source panel.
|
||||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
const exporter = (dash && bus)
|
||||
? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC })
|
||||
: null
|
||||
|
||||
const activeSlug = ref(null)
|
||||
const unsubs = []
|
||||
onMounted(() => {
|
||||
if (!bus) return
|
||||
unsubs.push(bus.on('focusRow', (p) => { if (p && p.slug) activeSlug.value = p.slug }))
|
||||
unsubs.push(bus.on('select', (set) => { const arr = set ? [...set] : []; if (arr.length) activeSlug.value = arr[arr.length - 1] }))
|
||||
})
|
||||
onBeforeUnmount(() => { unsubs.forEach((u) => { try { u() } catch (_) {} }); unsubs.length = 0 })
|
||||
|
||||
const record = computed(() => {
|
||||
const s = activeSlug.value
|
||||
if (!s || !dash || !dash.rowStore || !dash.rowStore.value) return null
|
||||
return dash.rowStore.value.get(s) || null
|
||||
})
|
||||
const fmt = (n) => (n === null || n === undefined || n === '' ? '—' : Number(n).toLocaleString('en-US'))
|
||||
const showPhoto = computed(() => {
|
||||
const r = record.value
|
||||
if (!r) return false
|
||||
return (cfg.detail && typeof cfg.detail.photo === 'function') ? !!cfg.detail.photo(r) : !!(r.has_photo && r.image_url)
|
||||
})
|
||||
|
||||
// ── Pray-for + More-information affordances (parity with research-map's
|
||||
// PeopleGroupDetail). We can't reuse that component here — it hard-injects
|
||||
// the map `uiStore`, pulls vue-i18n, and expects a GeoJSON
|
||||
// `peopleGroup.properties.*` shape, none of which exist in the dashboard
|
||||
// shell (flat `record` + dashboardData/dashboardConfig injects). So we
|
||||
// mirror just its two CTAs, styled with the --rd-* tokens below.
|
||||
//
|
||||
// Targets stay reusable via cfg.detail.{prayUrl,moreInfoUrl,prayLabel,
|
||||
// moreInfoLabel}. Each URL spec may be a function (record) => string, or a
|
||||
// string template with a {slug} placeholder; defaults derive from record.slug
|
||||
// and mirror research-map's prayUrl()/researchUrl().
|
||||
const detailCfg = computed(() => cfg.detail || {})
|
||||
function resolveUrl(spec, r, fallback) {
|
||||
if (typeof spec === 'function') { try { return spec(r) || fallback } catch (_) { return fallback } }
|
||||
if (typeof spec === 'string' && spec) return spec.replace('{slug}', encodeURIComponent(r.slug || ''))
|
||||
return fallback
|
||||
}
|
||||
const prayHref = computed(() => {
|
||||
const r = record.value
|
||||
if (!r) return null
|
||||
const def = `https://pray.doxa.life/${encodeURIComponent(r.slug || '')}?source=doxalife`
|
||||
return resolveUrl(detailCfg.value.prayUrl, r, def)
|
||||
})
|
||||
const moreInfoHref = computed(() => {
|
||||
const r = record.value
|
||||
if (!r) return null
|
||||
const origin = (typeof window !== 'undefined' && window.location?.origin) || 'https://doxa.life'
|
||||
const def = `${origin}/research/${encodeURIComponent(r.slug || '')}/`
|
||||
return resolveUrl(detailCfg.value.moreInfoUrl, r, def)
|
||||
})
|
||||
const prayLabel = computed(() => detailCfg.value.prayLabel || 'Pray for this people group')
|
||||
const moreInfoLabel = computed(() => detailCfg.value.moreInfoLabel || 'More information')
|
||||
|
||||
function exportThis() {
|
||||
if (!exporter || !bus) return
|
||||
if (!bus.selectionSet.size && activeSlug.value) bus.toggleSelect(activeSlug.value)
|
||||
exporter.exportCsv()
|
||||
}
|
||||
|
||||
// Dismiss the detail: clear the focused slug AND the bus selection so the pick is
|
||||
// deselected everywhere (drawer + map highlight + table), mirroring research-map's
|
||||
// PeopleGroupDetail close. Without this the popup had no way to close.
|
||||
function clearDetail() {
|
||||
activeSlug.value = null
|
||||
if (bus && typeof bus.clearSelection === 'function') bus.clearSelection()
|
||||
}
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-detail{ box-sizing:border-box; background:var(--rd-panel,#fff); border-left:1px solid var(--rd-line,#e2e6ec);
|
||||
border-bottom:1px solid var(--rd-line,#e2e6ec); padding:var(--s3,12px);
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; color:var(--rd-ink,#1f2733); flex:0 0 auto; }
|
||||
.rd-detail .rd-dh{ display:flex; align-items:center; justify-content:space-between; gap:8px;
|
||||
font-weight:700; font-size:var(--fs-md,12px); text-transform:uppercase; letter-spacing:.5px;
|
||||
color:var(--rd-muted,#6b7686); margin-bottom:var(--s2,8px); }
|
||||
.rd-detail .rd-detail-close{ background:none; border:none; padding:2px 4px; cursor:pointer; line-height:1;
|
||||
font-size:15px; color:var(--rd-muted,#6b7686); border-radius:var(--r-md,6px); flex-shrink:0; }
|
||||
.rd-detail .rd-detail-close:hover{ color:var(--rd-ink,#1f2733); background:var(--rd-bg,#f7f9fb); }
|
||||
.rd-detail .rd-empty{ font-size:var(--fs-md,12px); color:var(--rd-muted,#6b7686); padding:var(--s1,4px) 0; }
|
||||
.rd-detail .rd-top{ display:flex; gap:var(--s3,12px); align-items:flex-start; }
|
||||
.rd-detail .rd-thumb{ width:64px; height:64px; border-radius:var(--r-md,6px); object-fit:cover; flex:0 0 auto;
|
||||
border:1px solid var(--rd-line,#e2e6ec); display:block; }
|
||||
.rd-detail .rd-ph{ width:64px; height:64px; border-radius:var(--r-md,6px); flex:0 0 auto; background:var(--rd-bg,#f7f9fb);
|
||||
border:1px solid var(--rd-line,#e2e6ec); display:flex; align-items:center; justify-content:center;
|
||||
color:var(--rd-muted,#6b7686); font-size:var(--fs-xs,10px); text-transform:uppercase; letter-spacing:.4px; }
|
||||
.rd-detail .rd-name{ font-weight:800; font-size:var(--fs-xl,16px); line-height:1.2; color:var(--rd-ink,#1f2733); }
|
||||
.rd-detail .rd-meta{ font-size:var(--fs-sm,11px); color:var(--rd-muted,#6b7686); margin-top:2px; }
|
||||
.rd-detail .rd-rows{ display:flex; flex-direction:column; gap:2px; margin-top:var(--s3,12px); }
|
||||
.rd-detail .rd-row{ display:flex; align-items:baseline; gap:var(--s2,8px); font-size:var(--fs-md,12px);
|
||||
padding:2px 0; border-bottom:1px solid var(--rd-line,#e2e6ec); }
|
||||
.rd-detail .rd-row:last-child{ border-bottom:0; }
|
||||
.rd-detail .rd-row .k{ color:var(--rd-muted,#6b7686); flex:0 0 auto; min-width:118px; }
|
||||
.rd-detail .rd-row .v{ color:var(--rd-ink,#1f2733); margin-left:auto; text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.rd-detail .rd-row .v .fam{ color:var(--rd-muted,#6b7686); }
|
||||
.rd-detail .rd-exp{ margin-top:var(--s3,12px); width:100%; border:0; border-radius:var(--r-md,6px); cursor:pointer;
|
||||
background:var(--rd-accent,#e67e22); color:#fff; font-weight:700; font-size:var(--fs-md,12px); padding:var(--s2,8px) var(--s3,12px); }
|
||||
.rd-detail .rd-exp:hover{ background:#d8741c; }
|
||||
.rd-detail .rd-actions{ display:flex; gap:var(--s2,8px); margin-top:var(--s3,12px); flex-wrap:wrap; }
|
||||
.rd-detail .rd-act{ display:inline-flex; align-items:center; justify-content:center; gap:6px; text-decoration:none;
|
||||
border:0; border-radius:var(--r-md,6px); cursor:pointer; font-weight:700; font-size:var(--fs-md,12px);
|
||||
padding:var(--s2,8px) var(--s3,12px); line-height:1; }
|
||||
.rd-detail .rd-act svg{ flex:0 0 auto; }
|
||||
.rd-detail .rd-act-pray{ flex:1 1 auto; background:var(--rd-pray,#73A17F); color:#fff; }
|
||||
.rd-detail .rd-act-pray:hover{ background:var(--rd-pray-hover,#5f8c6a); }
|
||||
.rd-detail .rd-act-info{ flex:0 1 auto; background:var(--rd-bg,#f7f9fb); color:var(--rd-ink,#1f2733);
|
||||
border:1px solid var(--rd-line,#e2e6ec); }
|
||||
.rd-detail .rd-act-info:hover{ background:var(--rd-panel,#fff); border-color:var(--rd-muted,#6b7686); }
|
||||
`, 'rd-detaildrawer')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rd-detail" data-region="detail-drawer">
|
||||
<div class="rd-dh">
|
||||
<span>Detail</span>
|
||||
<button v-if="record" class="rd-detail-close" type="button" aria-label="Close detail" data-detail-close @click="clearDetail">✕</button>
|
||||
</div>
|
||||
<div v-if="!record" class="rd-empty" data-detail-empty>Select a people group to see detail</div>
|
||||
<template v-else>
|
||||
<div class="rd-top">
|
||||
<img v-if="showPhoto" class="rd-thumb" :src="record.image_url" :alt="record.name" data-detail-thumb />
|
||||
<div v-else class="rd-ph" data-detail-thumb>No photo</div>
|
||||
<div>
|
||||
<div class="rd-name" data-detail-name>{{ record.name || '—' }}</div>
|
||||
<div class="rd-meta">{{ lbl(record.country_code) || '—' }} · {{ lbl(record.religion) || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rd-actions">
|
||||
<a v-if="prayHref" class="rd-act rd-act-pray" :href="prayHref" target="_blank" rel="noopener" :title="prayLabel" data-detail-pray>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2L10 6L14 6.5L11 9.5L11.5 14L8 12L4.5 14L5 9.5L2 6.5L6 6L8 2Z" fill="currentColor"/></svg>
|
||||
<span>{{ prayLabel }}</span>
|
||||
</a>
|
||||
<a v-if="moreInfoHref" class="rd-act rd-act-info" :href="moreInfoHref" target="_blank" rel="noopener" :title="moreInfoLabel" data-detail-moreinfo>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="2" fill="none"/><path d="M8 6V8M8 10H8.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span>{{ moreInfoLabel }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="rd-rows">
|
||||
<div class="rd-row"><span class="k">Language</span><span class="v">{{ lbl(record.primary_language) || '—' }}<span v-if="record.imb_language_family" class="fam"> ({{ record.imb_language_family }})</span></span></div>
|
||||
<div class="rd-row"><span class="k">Population</span><span class="v">{{ fmt(record.population) }}</span></div>
|
||||
<div class="rd-row"><span class="k">People praying</span><span class="v">{{ fmt(record.people_praying) }}</span></div>
|
||||
<div class="rd-row"><span class="k">People committed</span><span class="v">{{ fmt(record.people_committed) }}</span></div>
|
||||
<div class="rd-row"><span class="k">Adopted by churches</span><span class="v">{{ fmt(record.adopted_by_churches) }}</span></div>
|
||||
<div class="rd-row"><span class="k">Engagement</span><span class="v">{{ lbl(record.engagement_status) || '—' }}</span></div>
|
||||
</div>
|
||||
<button class="rd-exp" type="button" data-detail-export @click="exportThis">Export this selection ↓</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
<script setup>
|
||||
/**
|
||||
* DashboardFacetRail (was Sidebar.vue) — RDASH redesign phase 2: the floating @map SemanticTreeLegend is DROPPED.
|
||||
*
|
||||
* Root cause it fixes: SemanticTreeLegend's `.stl-panel{position:absolute;width:380px;z-index:4}`
|
||||
* resolved against the shell and floated OVER the map (the #1 "overlapping" complaint). This is a
|
||||
* docked, in-flow, light facet rail that fills the "facets" grid cell — NO position:absolute.
|
||||
*
|
||||
* Loses NOTHING vs the tree: same facet groups, same setFilter wiring, same mapFocus cross-highlight.
|
||||
* The sections are now config-driven from `inject('dashboardConfig').facetRail` — each section is
|
||||
* either a multi-level drill (`{ drill:[facetKey,…] }`, expands under the active row) or a flat
|
||||
* single-facet count-list (`{ facet }`). Each row = label · micro-bar · count; the active row gets a
|
||||
* --rd-accent left rail. Reuses aggregate()/lbl() from @map/composables/useDashboardAggregations and
|
||||
* the same per-row facet → bus.setFilter wiring.
|
||||
*/
|
||||
import { inject, ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { aggregate, lbl } from '../../composables/useDashboardAggregations.js'
|
||||
import { useAffinityBlockLegendData } from '@map/composables/legends/useAffinityBlockLegendData.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
// DataSourceManager-normalized features (same store the geocoder bars use) — the shape
|
||||
// useAffinityBlockLegendData resolves labels from (affinityBlockLabel / clusterLabel / peopleGroupLabel).
|
||||
const dataStore = inject('dataStore', null)
|
||||
const searchSourceId = inject('searchSourceId', null)
|
||||
const filterSet = bus?.filterSet || {}
|
||||
|
||||
// facet-key -> row accessor (sourced from config) — drives both the count tally and setFilter.
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
// rows under the current filterSet, MINUS the facet(s) being aggregated (so each list stays navigable).
|
||||
function rowsExcluding(exclude) {
|
||||
const rows = dash?.rows?.value || []
|
||||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
if (!keys.length) return rows
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
}
|
||||
|
||||
// section collapse state (all open by default), keyed by section id.
|
||||
const open = reactive(Object.fromEntries((cfg.facetRail || []).map((s) => [s.id, true])))
|
||||
function toggle(sec) { open[sec] = !open[sec] }
|
||||
|
||||
function toRows(agg, facet, level) {
|
||||
const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1
|
||||
return agg.map((d) => ({
|
||||
facet, value: d.key, label: d.key, count: d.value, frac: d.value / max, level,
|
||||
other: !!d.other, active: !d.other && filterSet[facet] === d.key,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── single flat facet list (e.g. Religion / Status) ──
|
||||
function buildFacetRows(facet, topN) {
|
||||
return toRows(aggregate(rowsExcluding([facet]), FACET_ACC[facet], topN ?? 24), facet, 0)
|
||||
}
|
||||
|
||||
// ── multi-level drill (e.g. Place: region → block → country), drilling under whichever level is active ──
|
||||
// Excludes drill.slice(level) at each level so the list under the active parent stays navigable.
|
||||
function buildDrillRows(drill, topN) {
|
||||
const out = []
|
||||
const walk = (level) => {
|
||||
const facet = drill[level]
|
||||
const cap = (topN && topN[level]) ?? (level === drill.length - 1 ? 40 : 30)
|
||||
const rows = toRows(aggregate(rowsExcluding(drill.slice(level)), FACET_ACC[facet], cap), facet, level)
|
||||
for (const row of rows) {
|
||||
out.push(row)
|
||||
if (row.active && level < drill.length - 1) walk(level + 1)
|
||||
}
|
||||
}
|
||||
walk(0)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── People labels: single-sourced from the SAME resolver the research-map's semantic tree uses ──
|
||||
// The People facets key by CODE (from config.facets). We resolve those codes → human names by
|
||||
// running useAffinityBlockLegendData over the shell's normalized features (NOT a re-derived
|
||||
// hierarchy), so bloc/cluster/PG names in this rail are always identical to the map's tree.
|
||||
const normalizedFeatures = computed(() => {
|
||||
const src = dataStore && searchSourceId && dataStore.sources && dataStore.sources[searchSourceId]
|
||||
return (src && src.features) || []
|
||||
})
|
||||
const { aggregated: affinityAgg } = useAffinityBlockLegendData(normalizedFeatures)
|
||||
// code → label lookups keyed to the People facet keys, walked from the composable's aggregate tree.
|
||||
const affinityLabelMaps = computed(() => {
|
||||
const bloc = new Map(), cluster = new Map(), pg = new Map()
|
||||
for (const b of affinityAgg.value.values()) {
|
||||
if (b.code) bloc.set(b.code, b.label)
|
||||
for (const c of b.clusters.values()) {
|
||||
if (c.code) cluster.set(c.code, c.label)
|
||||
for (const g of c.peopleGroups.values()) { if (g.code) pg.set(g.code, g.label) }
|
||||
}
|
||||
}
|
||||
return { affinity_bloc: bloc, cluster, people_group: pg }
|
||||
})
|
||||
|
||||
const sections = computed(() => (cfg.facetRail || []).map((s) => {
|
||||
let rows = s.drill ? buildDrillRows(s.drill, s.topN) : buildFacetRows(s.facet, s.topN)
|
||||
// For a tree-backed section (People), swap the aggregated CODE keys for the composable's human
|
||||
// names — display only; facet/value/filter identity stays the code so filtering matches the map.
|
||||
if (s.tree === 'affinity') {
|
||||
const maps = affinityLabelMaps.value
|
||||
rows = rows.map((r) => (r.other ? r : { ...r, label: (maps[r.facet] && maps[r.facet].get(r.value)) || r.label }))
|
||||
}
|
||||
return { id: s.id, title: s.title, rows }
|
||||
}))
|
||||
|
||||
// header: full-filterSet scope count (unchanged logic from the tree header).
|
||||
const scopeCount = computed(() => {
|
||||
const rows = dash?.rows?.value || []
|
||||
const keys = Object.keys(filterSet)
|
||||
if (!keys.length) return rows.length
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })).length
|
||||
})
|
||||
|
||||
// each rendered row already carries its own facet key (data-facet/data-value).
|
||||
function onRowClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-facet]')
|
||||
if (!el || !bus) return
|
||||
const facet = el.getAttribute('data-facet'); const value = el.getAttribute('data-value')
|
||||
if (!facet || !value) return
|
||||
bus.setFilter(facet, filterSet[facet] === value ? null : value)
|
||||
}
|
||||
|
||||
// cross-highlight: when the map (or another panel) brushes a value, scroll the rail to it.
|
||||
// scrollIntoView would scroll the host page (shadow DOM) — set the container scrollTop instead.
|
||||
const railEl = ref(null)
|
||||
onMounted(() => {
|
||||
bus && bus.on('mapFocus', ({ value } = {}) => {
|
||||
if (!value || !railEl.value) return
|
||||
const rows = [...railEl.value.querySelectorAll('.fr-row')]
|
||||
const hit = rows.find((r) => r.textContent && r.textContent.includes(value))
|
||||
if (hit) railEl.value.scrollTop = Math.max(0, hit.offsetTop - 8)
|
||||
})
|
||||
})
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-facets{ display:flex; flex-direction:column; height:100%; min-height:0; box-sizing:border-box;
|
||||
background:var(--rd-panel,#fff); }
|
||||
.rd-facets .fr-head{ flex:0 0 auto; padding:var(--s3,12px) var(--s3,12px) var(--s2,8px); border-bottom:1px solid var(--rd-line,#e2e6ec); }
|
||||
.rd-facets .fr-scope{ font-size:11px; color:var(--rd-muted,#6b7686); } .rd-facets .fr-scope b{ color:var(--rd-ink,#1f2733); font-size:13px; }
|
||||
.rd-facets .fr-body{ flex:1 1 auto; min-height:0; overflow:auto; }
|
||||
.fr-sec{ border-bottom:1px solid var(--rd-line,#e2e6ec); }
|
||||
.fr-sech{ display:flex; align-items:center; gap:6px; padding:8px 12px; cursor:pointer; user-select:none;
|
||||
font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted,#6b7686); }
|
||||
.fr-sech:hover{ background:var(--rd-bg,#f7f9fb); }
|
||||
.fr-sech .chev{ margin-left:auto; transition:transform .15s; font-size:10px; color:#9aa4b2; }
|
||||
.fr-sech.closed .chev{ transform:rotate(-90deg); }
|
||||
.fr-rows{ padding-bottom:6px; }
|
||||
.fr-row{ position:relative; display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; font-size:12px; color:#3a4456; }
|
||||
.fr-row:hover{ background:#f1f4f8; }
|
||||
.fr-row.l1{ padding-left:26px; } .fr-row.l2{ padding-left:38px; }
|
||||
.fr-row.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink,#1f2733); }
|
||||
.fr-row.on::before{ content:''; position:absolute; left:0; top:2px; bottom:2px; width:3px; background:var(--rd-accent,#e67e22); border-radius:0 2px 2px 0; }
|
||||
.fr-row.other{ cursor:default; color:#9aa4b2; }
|
||||
.fr-row.other:hover{ background:transparent; }
|
||||
.fr-lbl{ flex:0 1 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.fr-bar{ flex:1 1 auto; height:6px; min-width:24px; background:#eef1f6; border-radius:3px; overflow:hidden; }
|
||||
.fr-bar i{ display:block; height:100%; background:#c7d6ea; border-radius:3px; transition:width .3s; }
|
||||
.fr-row.on .fr-bar i{ background:var(--rd-accent,#e67e22); }
|
||||
.fr-cnt{ flex:0 0 auto; font-variant-numeric:tabular-nums; color:#6b7686; min-width:30px; text-align:right; font-size:11px; }
|
||||
`, 'rd-facetrail')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rd-facets" data-region="facets">
|
||||
<div class="fr-head">
|
||||
<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">
|
||||
|
||||
<div v-for="sec in sections" :key="sec.id" class="fr-sec" :data-facet-section="sec.id">
|
||||
<div class="fr-sech" :class="{ closed: !open[sec.id] }" @click.stop="toggle(sec.id)"><span>{{ sec.title }}</span><span class="chev">▾</span></div>
|
||||
<div v-show="open[sec.id]" class="fr-rows">
|
||||
<div v-for="r in sec.rows" :key="r.facet + ':' + r.value" class="fr-row" :class="['l' + r.level, { on: r.active, other: r.other }]"
|
||||
:data-facet="r.other ? null : r.facet" :data-value="r.other ? null : r.value">
|
||||
<span class="fr-lbl">{{ r.label }}</span>
|
||||
<span class="fr-bar"><i :style="{ width: Math.max(4, r.frac * 100) + '%' }"></i></span>
|
||||
<span class="fr-cnt">{{ r.count.toLocaleString('en-US') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script setup>
|
||||
/**
|
||||
* FilterChips — RDASH phase 5: the live filterSet rendered as removable AND-stacked chips
|
||||
* (Islam ✕ | South Asia ✕ | …) + Clear all. The chip bar IS the live query state (plan §INTERACTION)
|
||||
* and is what CSV export reads later. Click delegation on the bar (defineCustomElement-safe).
|
||||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
const filterSet = bus?.filterSet || {}
|
||||
const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, text: String(v), facet: cfg.facets?.[k]?.label || k })))
|
||||
|
||||
function onClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-chip]')
|
||||
if (!el || !bus) return
|
||||
const k = el.getAttribute('data-chip')
|
||||
if (k === '__all__') bus.clearFilters(); else bus.setFilter(k, null)
|
||||
}
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-chips{ display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||||
.rd-chip{ display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:600; padding:3px 9px; border-radius:999px;
|
||||
background:#fff; color:#1a1a2e; border:1px solid #e67e22; cursor:pointer; line-height:1.2; }
|
||||
.rd-chip .fc{ opacity:.55; font-weight:500; } .rd-chip i{ font-style:normal; opacity:.6; font-size:12px; }
|
||||
.rd-chip:hover{ background:#fff4ea; } .rd-chip:hover i{ opacity:1; }
|
||||
.rd-chip.rd-clear{ border-color:#c7ccd6; color:#5a6577; } .rd-chip.rd-clear:hover{ background:#eef1f6; }
|
||||
`, 'rd-chips')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="chips.length" class="rd-chips" data-chips-bar @click="onClick">
|
||||
<span v-for="c in chips" :key="c.k" class="rd-chip" :data-chip="c.k" data-filter-chip>
|
||||
<span class="fc">{{ c.facet }}:</span>{{ c.text }} <i>✕</i>
|
||||
</span>
|
||||
<span class="rd-chip rd-clear" data-chip="__all__">Clear all</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
<script setup>
|
||||
/**
|
||||
* MapPanel — RDASH phase 7: all-regions/all-countries choropleth (shaded by UPG count) + magnitude
|
||||
* bubbles, linked to filterSet/selectionSet. Reuses read-only @map useMapInstance (no edits). The
|
||||
* choropleth is a feature-state-free match on iso_3166_1_alpha_3 over country-boundaries-v1; clicking
|
||||
* a country brushes filterSet (table+charts+KPI+tree narrow); filterSet dims non-matching countries.
|
||||
* Shadow-DOM safe — useMapInstance injects Mapbox GL CSS into the root; styles via useShadowStyles.
|
||||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount, watch, unref } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
const mapboxToken = inject('mapboxToken', ref(''))
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
const filterSet = bus?.filterSet || {}
|
||||
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
const filteredRows = computed(() => {
|
||||
const rows = dash?.rows?.value || []
|
||||
const keys = Object.keys(filterSet)
|
||||
if (!keys.length) return rows
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
})
|
||||
// Geo join is config-driven: geoFacet selects the facet whose `geoIso` accessor yields the ISO-3 join
|
||||
// value (e.g. 'IND') for the choropleth, and whose `get` accessor yields the filter label (e.g. 'India').
|
||||
const geoFacet = cfg.map?.geoFacet || 'country_code'
|
||||
const geoIso = cfg.facets?.[geoFacet]?.geoIso || ((r) => { const c = r[geoFacet]; return c && c.value ? c.value : null })
|
||||
const geoLabelOf = FACET_ACC[geoFacet] || ((r) => lbl(r[geoFacet]))
|
||||
// ISO3 -> {count, label}. Country code value is alpha-3 (e.g. 'IND').
|
||||
const isoToLabel = computed(() => {
|
||||
const m = new Map()
|
||||
for (const r of (dash?.rows?.value || [])) { const iso = geoIso(r); if (iso) m.set(iso, geoLabelOf(r) || iso) }
|
||||
return m
|
||||
})
|
||||
const countryCounts = computed(() => {
|
||||
const m = new Map()
|
||||
for (const r of filteredRows.value) { const iso = geoIso(r); if (iso) m.set(iso, (m.get(iso) || 0) + 1) }
|
||||
return m
|
||||
})
|
||||
|
||||
const mapContainer = ref(null)
|
||||
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
|
||||
containerRef: mapContainer, accessToken: unref(mapboxToken) || (mapboxToken && mapboxToken.value) || '',
|
||||
center: [28, 18], zoom: 1.45, minZoom: 1,
|
||||
})
|
||||
let added = false
|
||||
// Container-size watcher: Mapbox only re-reads its canvas size on window resize / its own
|
||||
// 'resize' event, so when the LAYOUT changes the cell (e.g. the table collapses and this map's
|
||||
// grid row grows), the canvas keeps its old size → a gray gap. A ResizeObserver on the map
|
||||
// container calls map.resize() on ANY container-size change, so the canvas always fills its cell.
|
||||
// General-correct (not table-specific): also covers window/rail/facet reflow. rAF-coalesced.
|
||||
let containerRO = null
|
||||
let _resizeRaf = 0
|
||||
function scheduleMapResize() {
|
||||
if (_resizeRaf) return
|
||||
_resizeRaf = requestAnimationFrame(() => {
|
||||
_resizeRaf = 0
|
||||
const m = map.value
|
||||
if (m && typeof m.resize === 'function') { try { m.resize() } catch (_) {} }
|
||||
})
|
||||
}
|
||||
|
||||
// Choropleth bins — config-driven (cfg.map.bins) with the default 5-bin grey→black ramp
|
||||
// (few people-groups = light grey, many = black). Override via cfg.map.bins.
|
||||
const BINS = cfg.map?.bins || [
|
||||
{ upTo: 5, c: '#e0e0e0', l: '1–4' }, { upTo: 20, c: '#b0b0b0', l: '5–19' }, { upTo: 60, c: '#808080', l: '20–59' },
|
||||
{ upTo: 150, c: '#404040', l: '60–149' }, { upTo: Infinity, c: '#000000', l: '150+' },
|
||||
]
|
||||
function colorFor(count) {
|
||||
if (!count) return '#f2f2f2'
|
||||
for (const b of BINS) { if (count < b.upTo) return b.c }
|
||||
return BINS[BINS.length - 1].c
|
||||
}
|
||||
// in-map legend (P5): the choropleth bins, shown bottom-right over the map.
|
||||
const LEGEND_BINS = BINS.map((b) => ({ c: b.c, l: b.l }))
|
||||
// Selection "pen": neon ring drawn over the currently-selected PG pin(s). Config-driven
|
||||
// (cfg.map.selectedColor) with a neon-orange default matching the research-map highlight.
|
||||
const SEL_COLOR = cfg.map?.selectedColor || '#ff6a00'
|
||||
function buildPaint() {
|
||||
const counts = countryCounts.value
|
||||
const colorPairs = []; const opacityIsos = []
|
||||
counts.forEach((cnt, iso) => { colorPairs.push(iso, colorFor(cnt)); opacityIsos.push(iso) })
|
||||
const fillColor = colorPairs.length ? ['match', ['get', 'iso_3166_1_alpha_3'], ...colorPairs, '#f2f2f2'] : '#f2f2f2'
|
||||
const anyFilter = Object.keys(filterSet).length > 0
|
||||
const fillOpacity = anyFilter && opacityIsos.length
|
||||
? ['match', ['get', 'iso_3166_1_alpha_3'], ...opacityIsos.flatMap((i) => [i, 0.92]), 0.12]
|
||||
: 0.78
|
||||
return { fillColor, fillOpacity }
|
||||
}
|
||||
function applyPaint() {
|
||||
const m = map.value; if (!m || !m.getLayer('rd-choropleth')) return
|
||||
const { fillColor, fillOpacity } = buildPaint()
|
||||
m.setPaintProperty('rd-choropleth', 'fill-color', fillColor)
|
||||
m.setPaintProperty('rd-choropleth', 'fill-opacity', fillOpacity)
|
||||
// bubbles source refresh
|
||||
const src = m.getSource('rd-bubbles')
|
||||
if (src) src.setData(bubbleGeo())
|
||||
}
|
||||
function bubbleGeo() {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: filteredRows.value.filter((r) => Number(r.latitude) && Number(r.longitude))
|
||||
.map((r) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [Number(r.longitude), Number(r.latitude)] }, properties: { pop: Number(r.population) || 0, slug: r.slug || '', name: r.name || '' } })),
|
||||
}
|
||||
}
|
||||
// Selection-pen geojson: the currently-selected PG(s) from the bus's selectionSet. Sourced from the
|
||||
// full row store (not filteredRows) so the pen stays visible even when the row is filtered out of scope.
|
||||
// Empty when nothing is selected → the highlight layer renders nothing.
|
||||
function selectedGeo() {
|
||||
const sel = bus?.selectionSet
|
||||
const rows = dash?.rows?.value || []
|
||||
if (!sel || !sel.size) return { type: 'FeatureCollection', features: [] }
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: rows.filter((r) => sel.has(r.slug) && Number(r.latitude) && Number(r.longitude))
|
||||
.map((r) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [Number(r.longitude), Number(r.latitude)] }, properties: { slug: r.slug || '', name: r.name || '' } })),
|
||||
}
|
||||
}
|
||||
function refreshSelected() {
|
||||
const m = map.value; if (!m) return
|
||||
const src = m.getSource('rd-bubble-selected')
|
||||
if (src) src.setData(selectedGeo())
|
||||
}
|
||||
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' })
|
||||
const { fillColor, fillOpacity } = buildPaint()
|
||||
m.addLayer({ id: 'rd-choropleth', type: 'fill', source: 'rd-countries', 'source-layer': 'country_boundaries',
|
||||
paint: { 'fill-color': fillColor, 'fill-opacity': fillOpacity, 'fill-outline-color': '#ffffff' } })
|
||||
m.addSource('rd-bubbles', { type: 'geojson', data: bubbleGeo() })
|
||||
m.addLayer({ id: 'rd-bubble-layer', type: 'circle', source: 'rd-bubbles',
|
||||
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 5], 'circle-color': '#e67e22', 'circle-opacity': 0.6, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' } })
|
||||
// selection pen — a prominent neon ring over the selected PG pin(s), drawn ABOVE the normal bubbles.
|
||||
m.addSource('rd-bubble-selected', { type: 'geojson', data: selectedGeo() })
|
||||
m.addLayer({ id: 'rd-bubble-selected', type: 'circle', source: 'rd-bubble-selected',
|
||||
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 5, 6, 11], 'circle-color': SEL_COLOR, 'circle-opacity': 0.18,
|
||||
'circle-stroke-width': 3, 'circle-stroke-color': SEL_COLOR, 'circle-stroke-opacity': 0.95 } })
|
||||
m.on('click', 'rd-choropleth', onMapClick)
|
||||
m.on('click', 'rd-bubble-layer', onBubbleClick)
|
||||
m.on('mouseenter', 'rd-choropleth', () => { m.getCanvas().style.cursor = 'pointer' })
|
||||
m.on('mouseleave', 'rd-choropleth', () => { m.getCanvas().style.cursor = '' })
|
||||
m.on('mouseenter', 'rd-bubble-layer', () => { m.getCanvas().style.cursor = 'pointer' })
|
||||
m.on('mouseleave', 'rd-bubble-layer', () => { m.getCanvas().style.cursor = '' })
|
||||
try {
|
||||
window.__rdashMap = m
|
||||
// verify hook: simulate a country click at a lng/lat (query the choropleth at that point)
|
||||
window.__rdashClickAt = (lng, lat) => {
|
||||
const pt = m.project([lng, lat])
|
||||
const feats = m.queryRenderedFeatures(pt, { layers: ['rd-choropleth'] })
|
||||
if (feats && feats[0]) { onMapClick({ features: feats }); return feats[0].properties && feats[0].properties.iso_3166_1_alpha_3 }
|
||||
return null
|
||||
}
|
||||
// verify hook: select the PG pin nearest a lng/lat (small bbox query around the projected point)
|
||||
window.__rdashPinAt = (lng, lat) => {
|
||||
const pt = m.project([lng, lat])
|
||||
const feats = m.queryRenderedFeatures([[pt.x - 7, pt.y - 7], [pt.x + 7, pt.y + 7]], { layers: ['rd-bubble-layer'] })
|
||||
if (feats && feats[0]) { onBubbleClick({ features: [feats[0]] }); return feats[0].properties && feats[0].properties.slug }
|
||||
return null
|
||||
}
|
||||
} catch (_) { /* verify hook */ }
|
||||
applyProjection()
|
||||
// selection pen: rebuild the highlight source whenever the selectionSet changes (toggle on/off).
|
||||
if (bus) bus.on('select', refreshSelected)
|
||||
refreshSelected()
|
||||
// pin focus -> fly the map to that people group (deepest drill level)
|
||||
if (bus) bus.on('focusRow', ({ slug } = {}) => {
|
||||
if (!slug) return
|
||||
const r = (dash?.rows?.value || []).find((x) => x.slug === slug)
|
||||
const mm = map.value
|
||||
if (mm && r && Number(r.latitude) && Number(r.longitude)) { try { mm.flyTo({ center: [Number(r.longitude), Number(r.latitude)], zoom: 5.5, duration: 1200 }) } catch (_) {} }
|
||||
})
|
||||
}
|
||||
function onMapClick(e) {
|
||||
// if a PG pin is under the cursor, let onBubbleClick SELECT it — don't also filter the country
|
||||
const m = map.value
|
||||
if (m && e.point) {
|
||||
const pins = m.queryRenderedFeatures([[e.point.x - 5, e.point.y - 5], [e.point.x + 5, e.point.y + 5]], { layers: ['rd-bubble-layer'] })
|
||||
if (pins && pins.length) return
|
||||
}
|
||||
const f = e.features && e.features[0]; if (!f || !bus) return
|
||||
const iso = f.properties && f.properties.iso_3166_1_alpha_3
|
||||
const label = isoToLabel.value.get(iso)
|
||||
if (!label) return
|
||||
bus.setFilter(geoFacet, filterSet[geoFacet] === label ? null : label)
|
||||
bus.emit('mapFocus', { facet: geoFacet, value: label })
|
||||
}
|
||||
// PG pin click -> SELECT the people group (selectionSet) + broadcast focus so the table scrolls/highlights.
|
||||
function onBubbleClick(e) {
|
||||
const f = e.features && e.features[0]; if (!f || !bus) return
|
||||
const slug = f.properties && f.properties.slug
|
||||
if (!slug) return
|
||||
bus.toggleSelect(slug)
|
||||
bus.emit('focusRow', { slug, name: f.properties && f.properties.name })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initializeMap()
|
||||
const m = map.value; if (!m) return
|
||||
if (m.isStyleLoaded()) addLayers(); else m.on('load', addLayers)
|
||||
// also guard: style may already be loaded by the time we attach
|
||||
m.on('idle', () => { if (!added && m.isStyleLoaded()) addLayers() })
|
||||
// watch the map container for size changes (table collapse/expand, rail reflow, window) and
|
||||
// resize the canvas so it always fills its cell — this is the real fix for the gray gap.
|
||||
if (typeof ResizeObserver !== 'undefined' && mapContainer.value) {
|
||||
containerRO = new ResizeObserver(scheduleMapResize)
|
||||
containerRO.observe(mapContainer.value)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (containerRO) { try { containerRO.disconnect() } catch (_) {} containerRO = null }
|
||||
if (_resizeRaf) { cancelAnimationFrame(_resizeRaf); _resizeRaf = 0 }
|
||||
try { destroy && destroy() } catch (_) {}
|
||||
})
|
||||
watch([filteredRows, isMapReady], applyPaint)
|
||||
|
||||
// ── 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 (_) {} } }
|
||||
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)
|
||||
// flies the map to fit the in-scope people groups; a pin focus flies to that PG (deeper drill). ──
|
||||
function boundsOf(rows) {
|
||||
const pts = rows.filter((r) => Number(r.latitude) && Number(r.longitude))
|
||||
if (!pts.length) return null
|
||||
let w = 180, s = 90, e = -180, n = -90
|
||||
for (const r of pts) { const x = Number(r.longitude), y = Number(r.latitude); if (x < w) w = x; if (x > e) e = x; if (y < s) s = y; if (y > n) n = y }
|
||||
return [[w, s], [e, n]]
|
||||
}
|
||||
function flyToFiltered() {
|
||||
const m = map.value; if (!m) return
|
||||
if (!Object.keys(filterSet).length) { try { m.flyTo({ center: [28, 18], zoom: 1.45, duration: 1000 }) } catch (_) {} ; return }
|
||||
const b = boundsOf(filteredRows.value)
|
||||
if (b) { try { m.fitBounds(b, { padding: 70, duration: 1200, maxZoom: 5.5 }) } catch (_) {} }
|
||||
}
|
||||
let _flyT = null
|
||||
watch(() => JSON.stringify(filterSet), () => { clearTimeout(_flyT); _flyT = setTimeout(flyToFiltered, 260) })
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-mapwrap{ position:relative; width:100%; height:100%; min-height:0; background:#dfe6ef; }
|
||||
.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; }
|
||||
.rd-legend-map{ position:absolute; right:8px; bottom:8px; z-index:2; background:#ffffffe8; border:1px solid var(--rd-line,#e2e6ec);
|
||||
border-radius:var(--r-md,6px); padding:6px 8px; backdrop-filter:blur(3px); box-sizing:border-box; max-width:148px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
||||
.rd-legend-map .lg-h{ font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted,#6b7686); margin-bottom:4px; }
|
||||
.rd-legend-map .lg-row{ display:flex; align-items:center; gap:6px; font-size:10px; color:#3a4456; line-height:1.5; }
|
||||
.rd-legend-map .lg-sw{ width:11px; height:11px; border-radius:2px; flex:0 0 auto; border:1px solid #00000014; }
|
||||
`, 'rd-mappanel')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-mapwrap" data-sub="map" :data-projection="projection">
|
||||
<div ref="mapContainer" class="rd-mapc" data-map-canvas></div>
|
||||
<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">
|
||||
<span class="lg-sw" :style="{ background: b.c }"></span><span>{{ b.l }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
<script setup>
|
||||
/**
|
||||
* DashboardSearchHeader — the 4 facet search bars (DOXA-LIB-3, the reuse proof).
|
||||
*
|
||||
* Renders one standalone GeocoderComponent per `config.search` entry (people-groups /
|
||||
* regions / language / religion). Each bar CONSUMES the SAME `useGeocoderSearch`
|
||||
* composable the map profiles use (LIB-1) — but swaps the map sink for a `busAdapter`
|
||||
* that lands the result on the dashboard's selectionBus. That is the headline reviewer
|
||||
* artifact: the kind-switch (KIND_PROPERTY + the per-kind filter-expression builder) runs
|
||||
* in EXACTLY ONE place — the composable — for BOTH the map profiles AND this dashboard.
|
||||
*
|
||||
* This component does NOT re-implement the kind resolution. The composable resolves the
|
||||
* geocoder event to { kind, value, property, expr }; the busAdapter only maps the already
|
||||
* RESOLVED `kind` to the dashboard's OWN facet key (wagf_region / country_code /
|
||||
* primary_language / religion) and lands a byte-match-safe value (per spec §8 — derived
|
||||
* from a member row, not the geocoder's display label).
|
||||
*
|
||||
* Brushing-and-linking: every panel re-derives from bus.filterSet / bus.selectionSet, so a
|
||||
* pick in any bar narrows the WHOLE dashboard (table, charts, facet rail, KPIs, map) with
|
||||
* zero panel edits — a new input source plugging into the bus.
|
||||
*
|
||||
* DATA (DASH-FIX-8): the geocoder's local suggestions come from the injected `dataStore`
|
||||
* (useDoxaSearch reads dataStore.sources[searchSourceId].features). The dashboard loads its
|
||||
* rows into its OWN store (useDashboardData), so useDashboardShell now ALSO normalizes those
|
||||
* already-fetched rows through the SAME DataSourceManager the map profiles use and exposes
|
||||
* them as a shared `dataStore` under `searchSourceId` (provided here via inject). That makes
|
||||
* useDoxaSearch build an IDENTICAL index → the dashboard bars produce the SAME suggestions as
|
||||
* research-map, with no second network request. The bar → composable → bus wiring below is
|
||||
* independent of that and is what this kit proves.
|
||||
*/
|
||||
import { inject, ref, computed, unref, onMounted } from 'vue'
|
||||
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
|
||||
import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
||||
const bus = inject('selectionBus', null)
|
||||
const dash = inject('dashboardData', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
const mapboxToken = inject('mapboxToken', ref(''))
|
||||
// The source id the shell keyed its DataSourceManager-normalized features under
|
||||
// (dataStore.sources[searchSourceId].features). Passed to each GeocoderComponent
|
||||
// as :data-source-id so useDoxaSearch reads that exact source — mirrors how the
|
||||
// map profiles wire :data-source-id="dataSource" (DASH-FIX-2).
|
||||
const searchSourceId = inject('searchSourceId', '')
|
||||
|
||||
const token = computed(() => unref(mapboxToken) || '')
|
||||
|
||||
// ── Host-independent geocoder-plugin loader ────────────────────────────────────
|
||||
// GeocoderComponent builds the bars from the global `MapboxGeocoder` (the
|
||||
// mapbox-gl-geocoder plugin). The MAP profiles get it from a <script> in their
|
||||
// host page; but a reusable dashboard bundle must NOT depend on the embedding
|
||||
// host remembering to add that script. So load the plugin from the Mapbox CDN
|
||||
// ourselves (mirrors how GeocoderComponent injects the geocoder CSS at runtime)
|
||||
// and only mount the bars once it's ready — works in ANY host with zero wiring.
|
||||
const GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
|
||||
const pluginReady = ref(false)
|
||||
function ensureGeocoderPlugin() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof window.MapboxGeocoder !== 'undefined') { pluginReady.value = true; return }
|
||||
let s = document.querySelector(`script[src="${GEOCODER_JS}"]`)
|
||||
if (!s) {
|
||||
s = document.createElement('script')
|
||||
s.src = GEOCODER_JS
|
||||
s.async = true
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
s.addEventListener('load', () => { pluginReady.value = true })
|
||||
// If another mount already loaded it between the check and here, don't hang.
|
||||
if (typeof window.MapboxGeocoder !== 'undefined') pluginReady.value = true
|
||||
}
|
||||
onMounted(ensureGeocoderPlugin)
|
||||
|
||||
// RESOLVED geocoder kind → the dashboard's OWN facet key. This is NOT a copy of the
|
||||
// composable's KIND_PROPERTY (which maps kind → a MAPBOX PIN property); these are the
|
||||
// dashboard facet keys the rest of the panels filter on. The composable already did the
|
||||
// kind-switch; here we only choose which facet bucket the resolved value lands in.
|
||||
const KIND_FACET = {
|
||||
region: 'wagf_region',
|
||||
country: 'country_code',
|
||||
block: 'wagf_block',
|
||||
province: 'wagf_block',
|
||||
religion: 'religion',
|
||||
'language-family': 'primary_language',
|
||||
language: 'primary_language',
|
||||
dialect: 'primary_language',
|
||||
}
|
||||
|
||||
// Resolve the BYTE-MATCH-SAFE facet value (spec §8). The geocoder's display label may not
|
||||
// byte-match what config.facets[facet].get(row) returns (e.g. country "India" vs ISO-3
|
||||
// "IND"); if it doesn't, setFilter narrows to an empty set ("blank dashboard"). So derive
|
||||
// the value FROM a member row using the SAME accessor the facet rail/table/charts use —
|
||||
// guaranteeing search-set and facet-set share one string space. Fall back to the resolved
|
||||
// value / display label only when no member row is found.
|
||||
function resolveFacetValue(facet, r, evt) {
|
||||
const acc = cfg.facets?.[facet]?.get
|
||||
const store = dash?.rowStore?.value
|
||||
const memberId = (evt?.memberIds && evt.memberIds[0]) || evt?.slug
|
||||
if (acc && store && memberId) {
|
||||
const row = store.get(memberId)
|
||||
if (row) {
|
||||
const v = acc(row)
|
||||
if (v != null && v !== '') return v
|
||||
}
|
||||
}
|
||||
return r?.value != null && r.value !== '' ? r.value : evt?.label
|
||||
}
|
||||
|
||||
// One bar = one GeocoderComponent + one useGeocoderSearch call (its own busAdapter).
|
||||
// config.search is fixed at setup time, so building the bars once is safe.
|
||||
const bars = (cfg.search || []).map((entry) => {
|
||||
const geocoderRef = ref(null)
|
||||
// each bar remembers what it last landed so clear() (the geocoder X) can undo
|
||||
// exactly its own contribution: a facet filter OR a member selection.
|
||||
const state = { _lastFacet: null, _selectedMembers: false }
|
||||
|
||||
// Land a group's member rows on the selectionSet in ONE emit. A bloc / cluster /
|
||||
// people-group AGGREGATE has no dashboard facet key (the dashboard facets are
|
||||
// region / country / block / religion / language), so instead of doing nothing we
|
||||
// MARK its member rows — the dashboard equivalent of research-map's "dim to just
|
||||
// this group's members". Uses only the exposed bus API (selectionSet + emit), so
|
||||
// useSelectionBus is untouched; one emit avoids N re-renders for a big PG (e.g.
|
||||
// "Deaf" spanning 100+ countries).
|
||||
function selectMembers(memberIds, name) {
|
||||
const members = Array.isArray(memberIds) ? memberIds.filter(Boolean) : []
|
||||
if (!members.length || !bus) return false
|
||||
bus.selectionSet.clear()
|
||||
for (const slug of members) bus.selectionSet.add(slug)
|
||||
bus.emit('select', bus.selectionSet)
|
||||
bus.emit('focusRow', { slug: members[0], name })
|
||||
return true
|
||||
}
|
||||
|
||||
const busAdapter = {
|
||||
// r = { kind, value, property, expr } already resolved by the composable.
|
||||
aggregate(r, evt) {
|
||||
if (!bus) return
|
||||
const facet = KIND_FACET[r.kind]
|
||||
if (facet) {
|
||||
const value = resolveFacetValue(facet, r, evt)
|
||||
if (value == null || value === '') return
|
||||
bus.setFilter(facet, value)
|
||||
state._lastFacet = facet
|
||||
return
|
||||
}
|
||||
// No dashboard facet for this resolved kind (affinity-bloc / cluster /
|
||||
// people-group aggregate) → brush the group's member rows instead of no-op.
|
||||
if (selectMembers(evt?.memberIds, r.value)) state._selectedMembers = true
|
||||
},
|
||||
peopleGroup(f) {
|
||||
const slug = f?.properties?.slug || f?.slug
|
||||
if (!slug || !bus) return
|
||||
bus.toggleSelect(slug)
|
||||
bus.emit('focusRow', { slug, name: f?.properties?.name })
|
||||
},
|
||||
clear() {
|
||||
if (!bus) return
|
||||
if (state._lastFacet) { bus.setFilter(state._lastFacet, null); state._lastFacet = null }
|
||||
if (state._selectedMembers) { bus.clearSelection(); state._selectedMembers = false }
|
||||
},
|
||||
}
|
||||
|
||||
// SAME composable as the map profiles — only the sink is swapped (busAdapter) and the
|
||||
// kinds are scoped to this bar's subset. No map / clustering / mapStore needed.
|
||||
const handlers = useGeocoderSearch({
|
||||
busAdapter,
|
||||
supportedKinds: entry.kinds,
|
||||
geocoderRef,
|
||||
mapId: entry.id,
|
||||
})
|
||||
|
||||
return { entry, geocoderRef, handlers }
|
||||
})
|
||||
|
||||
useShadowStyles(`
|
||||
/* position:relative + high z-index so the absolutely-positioned geocoder
|
||||
suggestion dropdowns render ABOVE the stats/map/table rows below this header.
|
||||
overflow MUST be visible — overflow-x:auto created a clipping context that
|
||||
hid the dropdown extending below the single-row header. */
|
||||
.rd-searchbar-header{ position:relative; z-index:60; display:flex; align-items:center; gap:8px;
|
||||
height:100%; padding:6px 14px; background:var(--rd-panel,#fff);
|
||||
border-bottom:1px solid var(--rd-line,#e2e6ec); box-sizing:border-box; overflow:visible; }
|
||||
.rd-searchbar{ position:relative; flex:1 1 0; min-width:150px; }
|
||||
.rd-searchbar .dg-standalone-geocoder .mapboxgl-ctrl-geocoder{ width:100%!important; max-width:none!important;
|
||||
box-shadow:none!important; border:1px solid var(--rd-line,#e2e6ec); border-radius:var(--r-md,6px);
|
||||
background:var(--rd-bg,#f7f9fb); }
|
||||
.rd-searchbar .dg-standalone-geocoder{ width:100%; }
|
||||
/* lift the mapbox-gl-geocoder suggestions list above sibling dashboard panels */
|
||||
.rd-searchbar .mapboxgl-ctrl-geocoder .suggestions,
|
||||
.rd-searchbar .mapboxgl-ctrl-geocoder ul.suggestions,
|
||||
.rd-searchbar .mapboxgl-ctrl-geocoder--results{ z-index:200!important; }
|
||||
`, 'rd-search-header')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-searchbar-header" data-region="search" data-sub="search-header">
|
||||
<div v-for="bar in bars" :key="bar.entry.id" class="rd-searchbar" :data-search-bar="bar.entry.id">
|
||||
<GeocoderComponent
|
||||
v-if="pluginReady"
|
||||
:ref="(el) => { bar.geocoderRef.value = el }"
|
||||
standalone
|
||||
:access-token="token"
|
||||
:data-source-id="searchSourceId"
|
||||
:placeholder="bar.entry.label"
|
||||
:active-context="bar.entry.context"
|
||||
@aggregate-result="bar.handlers.onAggregateResult"
|
||||
@people-group-result="bar.handlers.onPeopleGroupResult"
|
||||
@clear="bar.handlers.onClear" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<script setup>
|
||||
/**
|
||||
* DashboardTopBar — RDASH P4 Command Bar (topbar grid region, 52px).
|
||||
* One cohesive light surface (--rd-* tokens, no dark #1a1a2e, 1px --rd-line border, no shadow):
|
||||
* title · live typeahead search · inline Breadcrumb + FilterChips · CSV export.
|
||||
*
|
||||
* Search is the new linking entry point: type a name → dropdown of matching people groups →
|
||||
* pick (click / Enter) → bus.toggleSelect(slug) + emit('focusRow') which the existing subscribers
|
||||
* already honor (MapPanel flyTo, DataTable scroll-to-row, DetailDrawer fill).
|
||||
* All CSS via useShadowStyles (scoped <style> is dropped in the defineCustomElement shadow build).
|
||||
*
|
||||
* Config-driven (DOXA-LIB-2): the brand title and the FACET_ACC accessors are read from
|
||||
* inject('dashboardConfig') instead of being hard-coded — see ./README.md.
|
||||
*/
|
||||
import { inject, computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import DashboardFilterChips from './DashboardFilterChips.vue'
|
||||
import DashboardBreadcrumb from './DashboardBreadcrumb.vue'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
// The old RDASH command-bar typeahead is superseded by the 4 DashboardSearchHeader
|
||||
// facet bars (People/Place/Language/Religion). Feature-flag it OFF by default —
|
||||
// code kept warm; a dashboard can opt back in with config `showTopBarSearch: true`.
|
||||
const showTopBarSearch = computed(() => cfg.showTopBarSearch === true)
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
const csv = dash && bus ? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC }) : null
|
||||
const selCount = computed(() => (bus?.selectionSet ? bus.selectionSet.size : 0))
|
||||
const exportToast = ref('')
|
||||
let _toastT = null
|
||||
function onExport() {
|
||||
if (!csv) return
|
||||
const r = csv.exportCsv()
|
||||
exportToast.value = r.downloaded ? `✓ ${r.rows} rows exported` : r.clipboard ? `✓ ${r.rows} rows copied to clipboard` : `✓ ${r.rows} rows`
|
||||
clearTimeout(_toastT); _toastT = setTimeout(() => { exportToast.value = '' }, 2800)
|
||||
}
|
||||
|
||||
// "Align Dashboard" — snap the drag-resized layout back to defaults. Stays generic: TopBar just
|
||||
// emits on the shared selection bus; the profile's useDashboardLayout listens (bus.on('resetLayout',
|
||||
// reset)) and clears its localStorage key + inline vars. Desktop-only — hidden via CSS on .rd-narrow.
|
||||
function onAlignLayout() { if (bus) bus.emit('resetLayout') }
|
||||
|
||||
// ── live typeahead search over the in-memory row store ──
|
||||
const q = ref('')
|
||||
const open = ref(false)
|
||||
const active = ref(0)
|
||||
const results = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
if (term.length < 2) return []
|
||||
const rows = dash?.rows?.value || []
|
||||
const out = []
|
||||
for (const r of rows) {
|
||||
const name = (r.name || '').toLowerCase()
|
||||
if (name.includes(term)) { out.push(r); if (out.length >= 8) break }
|
||||
}
|
||||
return out
|
||||
})
|
||||
function focusResult(r) {
|
||||
if (!r || !bus) return
|
||||
if (r.slug) { bus.toggleSelect(r.slug); bus.emit('focusRow', { slug: r.slug, name: r.name }) }
|
||||
q.value = ''; open.value = false; active.value = 0
|
||||
}
|
||||
function onInput() { open.value = true; active.value = 0 }
|
||||
function onKeydown(e) {
|
||||
if (!results.value.length) { if (e.key === 'Escape') { q.value = ''; open.value = false } return }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); active.value = (active.value + 1) % results.value.length }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); active.value = (active.value - 1 + results.value.length) % results.value.length }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); focusResult(results.value[active.value]) }
|
||||
else if (e.key === 'Escape') { q.value = ''; open.value = false; active.value = 0 }
|
||||
}
|
||||
function onResultsClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-slug]')
|
||||
if (!el) return
|
||||
const slug = el.getAttribute('data-slug')
|
||||
const r = (dash?.rows?.value || []).find((x) => x.slug === slug)
|
||||
if (r) focusResult(r)
|
||||
}
|
||||
// close the dropdown on outside click (shadow-DOM safe: listen on the host root, not document)
|
||||
const rootEl = ref(null)
|
||||
function onDocClick(e) { if (rootEl.value && !rootEl.value.contains(e.target)) open.value = false }
|
||||
onMounted(() => { try { rootEl.value?.getRootNode()?.addEventListener('click', onDocClick) } catch (_) {} })
|
||||
onBeforeUnmount(() => { try { rootEl.value?.getRootNode()?.removeEventListener('click', onDocClick) } catch (_) {}; clearTimeout(_toastT) })
|
||||
|
||||
useShadowStyles(`
|
||||
/* DOXA green research-map header — matches research-map.vue site header (#4e594f / light ink #F3F3F1).
|
||||
Tokens stay overridable: --rd-header (green surface), --rd-header-ink (light text),
|
||||
--rd-header-muted (light muted for icons/placeholders/sub-text). */
|
||||
.rd-topbar{ position:relative; display:flex; align-items:center; gap:12px; height:100%; padding:0 14px;
|
||||
background:var(--rd-header,#4e594f); color:var(--rd-header-ink,#F3F3F1); border-bottom:1px solid var(--rd-header-line,#3c453d);
|
||||
box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
||||
.rd-topbar .rd-brand{ display:flex; align-items:baseline; gap:7px; flex:0 0 auto; }
|
||||
.rd-topbar .rd-ttl{ font-weight:800; font-size:var(--fs-lg,13px); letter-spacing:.2px; color:var(--rd-header-ink,#F3F3F1); }
|
||||
.rd-topbar .rd-ttl .dot{ color:var(--rd-accent,#e67e22); }
|
||||
.rd-topbar .rd-spacer{ flex:1; }
|
||||
/* search — translucent light field so typed text stays legible on the green bar */
|
||||
.rd-search{ position:relative; flex:0 1 280px; min-width:140px; }
|
||||
.rd-search input{ width:100%; box-sizing:border-box; height:30px; padding:0 10px 0 28px; font-size:var(--fs-md,12px);
|
||||
color:var(--rd-header-ink,#F3F3F1); background:#ffffff1f; border:1px solid #ffffff3d;
|
||||
border-radius:var(--r-md,6px); outline:none; }
|
||||
.rd-search input:focus{ border-color:var(--rd-accent,#e67e22); background:#ffffff33; }
|
||||
.rd-search input::placeholder{ color:var(--rd-header-muted,#cbc5b9); }
|
||||
.rd-search .ic{ position:absolute; left:8px; top:50%; transform:translateY(-50%); font-size:12px; color:var(--rd-header-muted,#cbc5b9); pointer-events:none; }
|
||||
.rd-results{ position:absolute; left:0; right:0; top:34px; z-index:60; background:#fff; border:1px solid var(--rd-line,#e2e6ec);
|
||||
border-radius:var(--r-md,6px); box-shadow:0 8px 24px #1f273322; overflow:hidden; max-height:320px; overflow-y:auto; }
|
||||
.rd-results .rd-res{ display:flex; align-items:baseline; gap:8px; padding:6px 10px; cursor:pointer; font-size:var(--fs-md,12px); }
|
||||
.rd-results .rd-res:hover, .rd-results .rd-res.on{ background:var(--rd-bg,#f7f9fb); }
|
||||
.rd-results .rd-res .nm{ font-weight:700; color:var(--rd-ink,#1f2733); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.rd-results .rd-res .sub{ margin-left:auto; color:var(--rd-muted,#6b7686); font-size:var(--fs-sm,11px); white-space:nowrap; flex:0 0 auto; }
|
||||
.rd-results .rd-empty{ padding:8px 10px; color:var(--rd-muted,#6b7686); font-size:var(--fs-sm,11px); }
|
||||
/* csv */
|
||||
.rd-topbar .rd-csv{ display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-sm,11px); font-weight:700; cursor:pointer;
|
||||
background:var(--rd-accent,#e67e22); color:#fff; border-radius:var(--r-md,6px); padding:5px 11px; user-select:none; flex:0 0 auto; }
|
||||
.rd-topbar .rd-csv:hover{ background:#d8741c; }
|
||||
.rd-topbar .rd-csv-n{ background:#ffffff33; border-radius:var(--r-pill,999px); padding:0 6px; font-size:10px; }
|
||||
.rd-topbar .rd-toast{ font-size:var(--fs-sm,11px); font-weight:700; color:#1a7f46; background:#e7f6ed; border:1px solid #b6e3c6;
|
||||
border-radius:var(--r-md,6px); padding:3px 9px; white-space:nowrap; flex:0 0 auto; }
|
||||
/* Align Dashboard — subtle ghost button on the green bar; snaps the dragged layout to defaults.
|
||||
Desktop-only: hidden whenever the shell is in its narrow/mobile mode (shared shadow root). */
|
||||
.rd-topbar .rd-align{ display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-sm,11px); font-weight:700;
|
||||
cursor:pointer; user-select:none; flex:0 0 auto; color:var(--rd-header-ink,#F3F3F1);
|
||||
background:#ffffff1f; border:1px solid #ffffff3d; border-radius:var(--r-md,6px); padding:5px 11px; }
|
||||
.rd-topbar .rd-align:hover{ background:#ffffff33; border-color:var(--rd-accent,#e67e22); color:#fff; }
|
||||
.rd-shell.rd-narrow .rd-topbar .rd-align{ display:none; }
|
||||
`, 'rd-topbar')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header ref="rootEl" class="rd-topbar" data-region="topbar">
|
||||
<span class="rd-brand"><span class="rd-ttl">{{ cfg.title || 'Research Dashboard' }}<span class="dot">.</span></span></span>
|
||||
<DashboardBreadcrumb />
|
||||
<DashboardFilterChips />
|
||||
<span class="rd-spacer" />
|
||||
<div v-if="showTopBarSearch" class="rd-search" data-search>
|
||||
<span class="ic">⌕</span>
|
||||
<input type="text" v-model="q" placeholder="Search people groups…" data-search-input
|
||||
aria-label="Search people groups" @input="onInput" @focus="onInput" @keydown="onKeydown" />
|
||||
<div v-if="open && q.trim().length >= 2" class="rd-results" data-search-results @click="onResultsClick">
|
||||
<template v-if="results.length">
|
||||
<div v-for="(r, i) in results" :key="r.slug" class="rd-res" :class="{ on: i === active }" :data-slug="r.slug">
|
||||
<span class="nm">{{ r.name }}</span>
|
||||
<span class="sub">{{ lbl(r.country_code) }} · {{ lbl(r.religion) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="rd-empty">No people groups match “{{ q.trim() }}”.</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rd-align" role="button" tabindex="0" data-align-btn
|
||||
title="Reset the dashboard layout to its default alignment"
|
||||
@click="onAlignLayout" @keydown.enter.prevent="onAlignLayout" @keydown.space.prevent="onAlignLayout">
|
||||
⤢ Align Dashboard
|
||||
</span>
|
||||
<span class="rd-csv" role="button" tabindex="0" data-csv-btn @click="onExport">
|
||||
CSV ↓<span v-if="selCount" class="rd-csv-n">{{ selCount }}</span>
|
||||
</span>
|
||||
<span v-if="exportToast" class="rd-toast" data-export-toast>{{ exportToast }}</span>
|
||||
</header>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* dashboards/index.js — barrel export for the config-driven dashboards kit (DOXA-LIB-2).
|
||||
*
|
||||
* A profile imports the panels it needs from one place:
|
||||
* import { DashboardTopBar, DashboardDataTable, DashboardMapPanel } from '@map/components/dashboards'
|
||||
*
|
||||
* Every panel is config-aware: it injects('dashboardConfig') (provided once by
|
||||
* useDashboardShell) instead of hard-coding FACET_ACC / COLUMNS / chart defs / KPI defs.
|
||||
* See ./README.md for the library-vs-profile rule and the build-a-dashboard steps.
|
||||
*
|
||||
* DashboardSearchHeader (DOXA-LIB-3) is the 4-bar facet search header: it renders one
|
||||
* standalone GeocoderComponent per config.search entry, each consuming the shared
|
||||
* useGeocoderSearch composable via a busAdapter sink (kind-switch stays in the composable).
|
||||
*/
|
||||
export { default as DashboardTopBar } from './DashboardTopBar.vue'
|
||||
export { default as DashboardFacetRail } from './DashboardFacetRail.vue'
|
||||
export { default as DashboardDataTable } from './DashboardDataTable.vue'
|
||||
export { default as DashboardChartsRail } from './DashboardChartsRail.vue'
|
||||
export { default as DashboardDetailDrawer } from './DashboardDetailDrawer.vue'
|
||||
export { default as DashboardFilterChips } from './DashboardFilterChips.vue'
|
||||
export { default as DashboardBreadcrumb } from './DashboardBreadcrumb.vue'
|
||||
export { default as DashboardMapPanel } from './DashboardMapPanel.vue'
|
||||
export { default as DashboardSearchHeader } from './DashboardSearchHeader.vue'
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* useCsvExport.js — RDASH phase 4: export-any-selection to CSV (native Blob, 0-dep).
|
||||
*
|
||||
* Priority model (plan §INTERACTION): selectionSet (explicit picks) > filterSet result (in scope) >
|
||||
* full dataset. Serializes the FULL stored record per row (flattening {value,label} -> label),
|
||||
* with a '#' provenance header line of the active chips, RFC-ish quoting, and CSV-injection
|
||||
* neutralization (leading = + - @ prefixed with a single quote). Shadow-DOM is a non-issue —
|
||||
* export touches no rendered DOM/styles.
|
||||
*/
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC }) {
|
||||
function pickRows() {
|
||||
if (selectionSet && selectionSet.size) {
|
||||
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
|
||||
}
|
||||
const keys = Object.keys(filterSet)
|
||||
if (keys.length) {
|
||||
return (rows.value || []).filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
}
|
||||
return rows.value || []
|
||||
}
|
||||
function scopeLabel() {
|
||||
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
|
||||
return Object.keys(filterSet).length ? 'filter result' : 'all'
|
||||
}
|
||||
function flatten(r) {
|
||||
const o = {}
|
||||
for (const k of Object.keys(r)) { const v = r[k]; o[k] = (v && typeof v === 'object') ? (v.label ?? v.value ?? JSON.stringify(v)) : v }
|
||||
return o
|
||||
}
|
||||
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
|
||||
function esc(v) {
|
||||
let s = v === null || v === undefined ? '' : String(v)
|
||||
if (/^[=+\-@]/.test(s)) s = "'" + s
|
||||
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
|
||||
return s
|
||||
}
|
||||
function buildCsv() {
|
||||
const picked = pickRows().map(flatten)
|
||||
const cols = [...picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())]
|
||||
const chips = Object.entries(filterSet).map(([k, v]) => `${k}=${v}`).join(' | ') || 'none'
|
||||
const provenance = `# Research Dashboard export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
|
||||
const lines = [provenance, cols.map(esc).join(','), ...picked.map((r) => cols.map((c) => esc(r[c])).join(','))]
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
// Last-export status for UI feedback (toast). The platform gem-frame iframe is sandboxed WITHOUT
|
||||
// `allow-downloads`, so the native <a download> is silently blocked there — CLIPBOARD is the robust
|
||||
// primary path (the iframe `allow`s clipboard-write); native download still works outside a sandbox.
|
||||
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0 }
|
||||
function exportCsv() {
|
||||
const csv = buildCsv()
|
||||
const rows = pickRows().length
|
||||
try { if (typeof window !== 'undefined') window.__rdashLastCsv = csv } catch (_) { /* verify hook */ }
|
||||
let clipboard = false, downloaded = false
|
||||
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
|
||||
try { if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) { /* no clipboard */ }
|
||||
// 2) native file download — works when NOT in a download-blocked sandbox
|
||||
try {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'research-dashboard.csv'; a.style.display = 'none'
|
||||
document.body.appendChild(a); a.click(); downloaded = true
|
||||
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
|
||||
} catch (_) { /* download blocked — clipboard above already has it */ }
|
||||
Object.assign(lastExport, { rows, clipboard, downloaded, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
|
||||
try { window.__rdashExportStatus = { rows, clipboard, downloaded } } catch (_) {}
|
||||
return { csv, rows, clipboard, downloaded }
|
||||
}
|
||||
return { exportCsv, buildCsv, pickRows, scopeLabel, lastExport }
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* useAggregations.js — RDASH phase 5: client-side facet aggregation + chart geometry + palettes.
|
||||
*
|
||||
* Hand-rolled (no ECharts: starter-kit node_modules is a symlink into frozen doxa-life — can't
|
||||
* install). SVG donut/bar geometry is pure math, renders in the shadow root, tweens via CSS.
|
||||
*/
|
||||
export const REGION_COLORS = {
|
||||
'Africa': '#e74c3c', 'Asia': '#3498db', 'Europe': '#2ecc71', 'Latin America': '#f39c12',
|
||||
'Middle East': '#9b59b6', 'North America': '#1abc9c', 'Oceania': '#e67e22', 'Other': '#95a5a6',
|
||||
}
|
||||
export const CATEGORICAL = ['#2456c7', '#e67e22', '#16a085', '#8e44ad', '#c0392b', '#27ae60', '#2c3e50', '#d35400', '#2980b9', '#7f8c8d', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e74c3c']
|
||||
|
||||
export const lbl = (v) => (v === null || v === undefined ? '' : (typeof v === 'object' ? (v.label ?? v.value ?? '') : v))
|
||||
|
||||
/** Count rows by a key accessor; collapse the long tail into 'Other'. */
|
||||
export function aggregate(rows, keyFn, topN) {
|
||||
const m = new Map()
|
||||
for (const r of rows) { const k = keyFn(r); if (k === null || k === undefined || k === '') continue; m.set(k, (m.get(k) || 0) + 1) }
|
||||
let arr = [...m.entries()].map(([key, value]) => ({ key, value })).sort((a, b) => b.value - a.value)
|
||||
if (topN && arr.length > topN) {
|
||||
const top = arr.slice(0, topN)
|
||||
const other = arr.slice(topN).reduce((s, x) => s + x.value, 0)
|
||||
if (other > 0) top.push({ key: 'Other', value: other, other: true })
|
||||
arr = top
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
/** Donut slice geometry (SVG path strings), starting at 12 o'clock, clockwise. */
|
||||
export function buildDonut(data, size = 132, innerRatio = 0.58) {
|
||||
const cx = size / 2, cy = size / 2, r = size / 2 - 3, ri = (size / 2 - 3) * innerRatio
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1
|
||||
let a = -Math.PI / 2
|
||||
return data.map((d, i) => {
|
||||
const a0 = a, a1 = a + (d.value / total) * Math.PI * 2; a = a1
|
||||
const big = (a1 - a0) > Math.PI ? 1 : 0
|
||||
const p = (ang, rad) => `${(cx + rad * Math.cos(ang)).toFixed(2)},${(cy + rad * Math.sin(ang)).toFixed(2)}`
|
||||
const path = `M${p(a0, r)} A${r},${r} 0 ${big} 1 ${p(a1, r)} L${p(a1, ri)} A${ri},${ri} 0 ${big} 0 ${p(a0, ri)} Z`
|
||||
return { ...d, path, pct: Math.round((d.value / total) * 100), color: colorFor(d.key, i) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Horizontal-bar geometry as fractions of the widest bar. */
|
||||
export function buildBars(data) {
|
||||
const max = data.reduce((m, d) => Math.max(m, d.value), 0) || 1
|
||||
return data.map((d, i) => ({ ...d, frac: d.value / max, color: colorFor(d.key, i) }))
|
||||
}
|
||||
|
||||
export function colorFor(key, i) { return REGION_COLORS[key] || CATEGORICAL[i % CATEGORICAL.length] }
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* useDashboardData.js — RDASH phase 2: the single in-memory row store (kills the hardcode).
|
||||
*
|
||||
* Loads ALL people groups once + /statistics into a slug-keyed store. Uses the SHARED data layer
|
||||
* — sources.json (active source = pray-tools, its endpoints + defaultFields + lang) and getApiBaseUrl
|
||||
* (window.MAP_APP_API_URL / VITE_API_BASE_URL / pray.doxa.life) — but keeps the RAW API record per
|
||||
* row rather than DataSourceManager's normalized projection: the dashboard table/charts/CSV reference
|
||||
* raw fields ({value,label} objects like country_code, wagf_region, religion) and the CSV export must
|
||||
* serialize the FULL API record. DataSourceManager.normalizeData renames fields to system names and
|
||||
* drops no-coordinate rows (geo set for the map layer, phase 7) — wrong shape for this store.
|
||||
*
|
||||
* Shadow-DOM safe: pure data, no DOM/style. Reuses read-only @map config without editing it.
|
||||
*/
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
||||
export function useDashboardData(dataOpts = {}) {
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const rowStore = shallowRef(new Map()) // slug (PK) -> full raw API record
|
||||
const rows = shallowRef([]) // array view of the same records
|
||||
const stats = ref(null) // /statistics global aggregates (KPI baselines)
|
||||
const total = ref(0) // rowStore size (live-data marker; mock had 3)
|
||||
|
||||
// Which sources.json source the rows come from. Honors the profile's declared
|
||||
// config.data.source (parameterizable) and falls back to the shared activeSource,
|
||||
// preserving current pray-tools behavior. Exposed so the shell can normalize the
|
||||
// SAME source's fieldMappings for the geocoder dataStore (rows + normalization stay
|
||||
// in lockstep — no desync if a dashboard ever points at a non-active source).
|
||||
const sourceId = dataOpts.source || sourcesConfig.activeSource
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const src = sourcesConfig?.sources?.[sourceId] || {}
|
||||
const base = getApiBaseUrl()
|
||||
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
|
||||
const fields = (src.defaultFields || []).join(',')
|
||||
const lang = src.queryParams?.lang || 'en'
|
||||
|
||||
// ── 1) bulk list (shared config: endpoint + defaultFields + lang + cache-bust) ──
|
||||
const listUrl = `${base}${bulk}?${fields ? `fields=${fields}&` : ''}lang=${lang}&_=${Date.now()}`
|
||||
const res = await fetch(listUrl, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`list HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
const raw = Array.isArray(json) ? json : (json.posts || json.data || json.results || [])
|
||||
const map = new Map()
|
||||
for (const r of raw) {
|
||||
const slug = r && (r.slug || r.id)
|
||||
if (slug) map.set(slug, r)
|
||||
}
|
||||
rowStore.value = map
|
||||
rows.value = raw
|
||||
total.value = map.size
|
||||
|
||||
// ── 2) /statistics seeds KPI baselines ──
|
||||
const statEndpoint = src.endpoints?.statistics || '/api/people-groups/statistics'
|
||||
try {
|
||||
const sres = await fetch(`${base}${statEndpoint}?_=${Date.now()}`, { cache: 'no-store' })
|
||||
if (sres.ok && (sres.headers.get('content-type') || '').includes('json')) {
|
||||
stats.value = await sres.json()
|
||||
}
|
||||
} catch (_) { /* KPI baselines optional; the list is the source of truth */ }
|
||||
|
||||
if (total.value === 0) error.value = 'No records loaded from the live API.'
|
||||
} catch (e) {
|
||||
error.value = (e && e.message) ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, rowStore, rows, stats, total, load, sourceId }
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
|
||||
*
|
||||
* A dashboard shell (see app-profiles/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 248px
|
||||
* --rail-w (right charts-rail column width) default 312px
|
||||
* --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),
|
||||
* clamps each to a sane min/max, writes the vars onto the shell root element as INLINE custom
|
||||
* properties, PERSISTS the chosen layout to localStorage on every drag-end, RESTORES it on init,
|
||||
* and offers reset() to clear the key and snap back to the exact defaults.
|
||||
*
|
||||
* COMPOSITION with the collapse-table fix (do NOT regress):
|
||||
* The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule
|
||||
* `.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0 }` comes LATER in source
|
||||
* order (same specificity) so COLLAPSE WINS over --table-h. Because we only touch the CSS var
|
||||
* (never the collapsed row template), collapsing hides the table regardless of a dragged height,
|
||||
* and expanding restores the user's dragged --table-h. The center map is 1fr and already
|
||||
* self-resizes via DashboardMapPanel's ResizeObserver → map.resize(); we add no second resize.
|
||||
*
|
||||
* NON-REGRESSION with the responsive slim rails:
|
||||
* `.rd-shell.rd-slim-rail{ --rail-w:240px }` / `.rd-shell.rd-slim-facet{ --facet-w:196px }` are
|
||||
* medium-width niceties set via CLASS. Inline styles always beat class rules, so we apply inline
|
||||
* vars ONLY once the layout is customized (restored-from-storage or dragged). An untouched
|
||||
* dashboard writes no inline vars → the slim classes and the exact CSS defaults are preserved.
|
||||
* reset() removes the inline vars entirely, fully handing control back to the stylesheet.
|
||||
*
|
||||
* DESKTOP-ONLY: every drag is gated on `!isNarrow`; narrow/mobile never resizes or persists.
|
||||
*
|
||||
* Library-parameterizable — the profile's identity (defaults + storage key) lives in options, not
|
||||
* here. Any dashboard grid exposing the same three vars can reuse this unchanged.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import('vue').Ref<HTMLElement|null>} options.shellRef ref holding the .rd-shell root el
|
||||
* @param {object=} options.bus selection bus; when given, listens bus.on('resetLayout', reset)
|
||||
* @param {import('vue').Ref<boolean>|boolean=} options.isNarrow desktop gate (true = mobile/narrow)
|
||||
* @param {string=} options.storageKey localStorage key (default 'rd-dashboard-layout')
|
||||
* @param {{facetW?:number, railW?:number, tableH?:string}=} options.defaults default track sizes
|
||||
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number]}=} options.clamps
|
||||
* min/max — facetW/railW in px, tableH in percent-of-shell-height
|
||||
*
|
||||
* @returns {{
|
||||
* facetW: import('vue').Ref<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
|
||||
* startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void,
|
||||
* startTableResize:(e:PointerEvent)=>void, reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>
|
||||
* }}
|
||||
*/
|
||||
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const DEFAULTS = { facetW: 248, railW: 312, tableH: '34%' }
|
||||
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70] }
|
||||
|
||||
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v))
|
||||
const pctNum = (v, fallback) => {
|
||||
const n = parseFloat(String(v))
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export function useDashboardLayout(options = {}) {
|
||||
const {
|
||||
shellRef,
|
||||
bus = null,
|
||||
isNarrow = false,
|
||||
storageKey = 'rd-dashboard-layout',
|
||||
} = options
|
||||
|
||||
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }
|
||||
const clamps = { ...CLAMPS, ...(options.clamps || {}) }
|
||||
|
||||
const facetW = ref(defaults.facetW) // px
|
||||
const railW = ref(defaults.railW) // px
|
||||
const tableH = ref(defaults.tableH) // string, e.g. '34%'
|
||||
const customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults?
|
||||
|
||||
const narrow = () => !!unref(isNarrow)
|
||||
|
||||
// ── write / clear the three inline CSS custom properties on the shell root ──
|
||||
function applyVars() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el || !customized.value) return
|
||||
el.style.setProperty('--facet-w', facetW.value + 'px')
|
||||
el.style.setProperty('--rail-w', railW.value + 'px')
|
||||
el.style.setProperty('--table-h', tableH.value)
|
||||
}
|
||||
function clearVars() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
el.style.removeProperty('--facet-w')
|
||||
el.style.removeProperty('--rail-w')
|
||||
el.style.removeProperty('--table-h')
|
||||
}
|
||||
|
||||
// ── persistence ──
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
facetW: facetW.value, railW: railW.value, tableH: tableH.value,
|
||||
}))
|
||||
} catch (_) { /* storage blocked / SSR — layout still works this session */ }
|
||||
}
|
||||
function restore() {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const s = JSON.parse(raw)
|
||||
if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW)
|
||||
if (Number.isFinite(s.railW)) railW.value = clamp(s.railW, clamps.railW)
|
||||
if (s.tableH != null) tableH.value = clamp(pctNum(s.tableH, pctNum(defaults.tableH, 34)), clamps.tableH) + '%'
|
||||
customized.value = true
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
// ── reset: clear the key + snap back to the EXACT defaults (hand control to the stylesheet) ──
|
||||
function reset() {
|
||||
try { localStorage.removeItem(storageKey) } catch (_) { /* ignore */ }
|
||||
facetW.value = defaults.facetW
|
||||
railW.value = defaults.railW
|
||||
tableH.value = defaults.tableH
|
||||
customized.value = false
|
||||
clearVars()
|
||||
}
|
||||
|
||||
// ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ──
|
||||
function startResize(axis, e) {
|
||||
if (narrow()) return
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
e.preventDefault()
|
||||
const rect = el.getBoundingClientRect()
|
||||
const startX = e.clientX
|
||||
const startFacet = facetW.value
|
||||
const startRail = railW.value
|
||||
const handleEl = e.currentTarget
|
||||
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
|
||||
|
||||
function move(ev) {
|
||||
if (axis === 'facet') {
|
||||
// left facet edge → dragging right widens the facet column
|
||||
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
|
||||
} else if (axis === 'rail') {
|
||||
// right rail's LEFT edge → dragging left widens the rail column
|
||||
railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW))
|
||||
} else if (axis === 'table') {
|
||||
// map/table divider → dragging up grows the table (height measured from shell bottom)
|
||||
const pct = ((rect.bottom - ev.clientY) / rect.height) * 100
|
||||
tableH.value = Math.round(clamp(pct, clamps.tableH)) + '%'
|
||||
}
|
||||
customized.value = true
|
||||
applyVars()
|
||||
}
|
||||
function up(ev) {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {}
|
||||
persist()
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
}
|
||||
|
||||
const startFacetResize = (e) => startResize('facet', e)
|
||||
const startRailResize = (e) => startResize('rail', e)
|
||||
const startTableResize = (e) => startResize('table', e)
|
||||
|
||||
// ── lifecycle: restore + apply after the shell el exists; wire the reset bus event ──
|
||||
let offReset = null
|
||||
onMounted(() => {
|
||||
if (restore()) applyVars()
|
||||
if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset)
|
||||
})
|
||||
onBeforeUnmount(() => { try { offReset && offReset() } catch (_) {} })
|
||||
|
||||
return {
|
||||
facetW, railW, tableH, customized,
|
||||
startFacetResize, startRailResize, startTableResize,
|
||||
startResize, reset, applyVars,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* useDashboardShell.js — the config-driven dashboard assembler (DOXA-LIB-2 foundation).
|
||||
*
|
||||
* Does generically what research-dashboard.vue's <script setup> did by hand: create one
|
||||
* brushing-and-linking selection bus, load the single in-memory row store, derive the
|
||||
* (formerly copy-pasted) FACET_ACC accessor map from config.facets, compute the linked
|
||||
* filteredRows + KPI strip + CSV exporter + shareable hash URL + responsive layout flags,
|
||||
* and provide() the bus/data/config so every panel injects instead of hard-coding.
|
||||
*
|
||||
* The next dashboard becomes a thin app-profile that passes a config object — no pasted
|
||||
* filter loops, no pasted accessor maps, no pasted KPI math. Declare the config once; the
|
||||
* shell wires the whole CMV (coordinated-multiple-views) surface.
|
||||
*
|
||||
* const { bus, data, FACET_ACC, filteredRows, kpis, exporter, responsive } = useDashboardShell(config)
|
||||
*
|
||||
* Provides (inject keys for panels):
|
||||
* - 'selectionBus' → the pub/sub bus (filterSet + selectionSet + on/emit/setFilter/toggleSelect)
|
||||
* - 'dashboardData' → the row store ({ rows, rowStore, total, loading, error, stats, load })
|
||||
* - 'dashboardConfig'→ the profile config (facets, facetRail, kpis, columns, charts, detail, search, title)
|
||||
* (mapboxToken is provided upstream by ProfileLoader; panels inject it directly.)
|
||||
*
|
||||
* Logic moved VERBATIM from research-dashboard.vue — filter predicate, KPI math, ResizeObserver
|
||||
* 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 { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { createSelectionBus } from './useSelectionBus.js'
|
||||
import { useDashboardData } from './useDashboardData.js'
|
||||
import { useCsvExport } from './useCsvExport.js'
|
||||
import { DataSourceManager } from '@map/api/DataSourceManager.js'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
||||
// tiny lodash-free mapValues: { k: v } -> { k: fn(v, k) }
|
||||
const mapValues = (obj, fn) =>
|
||||
Object.fromEntries(Object.entries(obj || {}).map(([k, v]) => [k, fn(v, k)]))
|
||||
|
||||
// number formatter (same as the old inline `fmt`); exported for the profile's KPI strip.
|
||||
export const fmt = (n) => (n === null || n === undefined ? '—' : Number(n).toLocaleString('en-US'))
|
||||
|
||||
/**
|
||||
* Generic KPI computation driven by config.kpis. Each kpi def declares a `kind`:
|
||||
* count → row count of the filtered scope (+ optional delta = hidden-by-filter count)
|
||||
* sum → Σ Number(row[field])
|
||||
* pct → round( rows.filter(test).length / rows.length * 100 )% ('—' when empty)
|
||||
* distinct → size of the set produced by an accessor (via config.facets[field][via], e.g. geoIso)
|
||||
* Mirrors the old statTotal/statPop/statPctUnreached/statCountries/statPraying/statAdoptedPct
|
||||
* computeds — only the definitions now come from config instead of being hand-written.
|
||||
*/
|
||||
function computeKpis(defs, filtered, all, FACET_ACC, config) {
|
||||
return (defs || []).map((k) => {
|
||||
let value
|
||||
let delta
|
||||
if (k.kind === 'count') {
|
||||
value = filtered.length
|
||||
if (k.showDelta) delta = Math.max(0, all.length - filtered.length)
|
||||
} else if (k.kind === 'sum') {
|
||||
value = filtered.reduce((s, r) => s + (Number(r[k.field]) || 0), 0)
|
||||
} else if (k.kind === 'pct') {
|
||||
value = filtered.length
|
||||
? Math.round((filtered.filter(k.test).length / filtered.length) * 100) + '%'
|
||||
: '—'
|
||||
} else if (k.kind === 'distinct') {
|
||||
const acc = k.via ? config?.facets?.[k.field]?.[k.via] : FACET_ACC[k.field]
|
||||
const set = new Set()
|
||||
filtered.forEach((r) => { const v = acc ? acc(r) : r[k.field]; if (v) set.add(v) })
|
||||
value = set.size
|
||||
}
|
||||
return { id: k.id, label: k.label, kind: k.kind, value, delta }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive layout flags via a ResizeObserver (shadow-DOM-reliable, unlike @container which
|
||||
* targets descendants not the container itself). Three steps so the map stays the hero:
|
||||
* <760 single-column + segmented control; <980 slim facet rail; <1180 slim charts/detail rail.
|
||||
* Also owns the collapsible table dock (DataTable chevron emits 'toggleTable') and the
|
||||
* segmented mobile view switch. Extracted VERBATIM from research-dashboard.vue.
|
||||
*/
|
||||
function useResponsiveShell(bus) {
|
||||
const shell = ref(null)
|
||||
const isNarrow = ref(false)
|
||||
const slimRail = ref(false)
|
||||
const slimFacet = ref(false)
|
||||
const mobileView = ref('map')
|
||||
const SEG = [{ k: 'map', l: 'Map' }, { k: 'facets', l: 'Filters' }, { k: 'table', l: 'Table' }, { k: 'charts', l: 'Charts' }]
|
||||
let ro = null
|
||||
|
||||
const tableCollapsed = ref(false)
|
||||
const facetCollapsed = ref(false) // left filters rail collapsed → map grows
|
||||
const railCollapsed = ref(false) // right charts rail collapsed → map grows
|
||||
|
||||
onMounted(() => {
|
||||
if (shell.value) {
|
||||
ro = new ResizeObserver(() => {
|
||||
const w = shell.value.clientWidth
|
||||
isNarrow.value = w < 760
|
||||
slimRail.value = w >= 760 && w < 1180
|
||||
slimFacet.value = w >= 760 && w < 980
|
||||
})
|
||||
ro.observe(shell.value)
|
||||
}
|
||||
// collapsible table dock (P5): DataTable's chevron emits 'toggleTable'
|
||||
if (bus) bus.on('toggleTable', (v) => { tableCollapsed.value = !!v })
|
||||
if (bus) bus.on('toggleFacet', (v) => { facetCollapsed.value = !!v })
|
||||
if (bus) bus.on('toggleRail', (v) => { railCollapsed.value = !!v })
|
||||
})
|
||||
onBeforeUnmount(() => ro && ro.disconnect())
|
||||
|
||||
function setView(e) { const el = e.target.closest('[data-view]'); if (el) mobileView.value = el.getAttribute('data-view') }
|
||||
const showFacets = computed(() => !isNarrow.value || mobileView.value === 'facets')
|
||||
const showMap = computed(() => !isNarrow.value || mobileView.value === 'map')
|
||||
const showTable = computed(() => !isNarrow.value || mobileView.value === 'table')
|
||||
const showCharts = computed(() => !isNarrow.value || mobileView.value === 'charts')
|
||||
|
||||
return {
|
||||
shell, isNarrow, slimRail, slimFacet, mobileView, SEG, tableCollapsed, facetCollapsed, railCollapsed,
|
||||
setView, showFacets, showMap, showTable, showCharts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shareable view-state hash URL: serialize filterSet to location.hash; restore on mount.
|
||||
* Extracted VERBATIM from research-dashboard.vue.
|
||||
*/
|
||||
function useHashSync(bus) {
|
||||
function syncHash() {
|
||||
try {
|
||||
const enc = encodeURIComponent(JSON.stringify(bus.filterSet))
|
||||
if (typeof location !== 'undefined') history.replaceState(null, '', '#f=' + enc)
|
||||
} 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)) }
|
||||
} catch (_) { /* ignore bad hash */ }
|
||||
})
|
||||
}
|
||||
|
||||
export function useDashboardShell(config) {
|
||||
// One brushing-and-linking bus, provided to every panel (publisher + subscriber).
|
||||
const bus = createSelectionBus()
|
||||
|
||||
// The single in-memory row store — one fetch on mount. (useDashboardData reads the shared
|
||||
// @map/config/sources.json activeSource + getApiBaseUrl; config.data may carry overrides.)
|
||||
const data = useDashboardData(config.data)
|
||||
|
||||
// THE single former-duplicated FACET_ACC, derived ONCE from config.facets (facetKey -> get).
|
||||
const FACET_ACC = mapValues(config.facets, (f) => f.get)
|
||||
|
||||
// linked scope: rows surviving every active facet predicate (panels fade the rest).
|
||||
const filteredRows = computed(() => {
|
||||
const rows = data.rows.value || []
|
||||
const keys = Object.keys(bus.filterSet)
|
||||
if (!keys.length) return rows
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }))
|
||||
})
|
||||
|
||||
// linked KPI strip — recomputes on every brush.
|
||||
const kpis = computed(() => computeKpis(config.kpis, filteredRows.value, data.rows.value || [], FACET_ACC, config))
|
||||
|
||||
// selection > filter > all CSV exporter (clipboard-primary, sandbox-safe).
|
||||
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC })
|
||||
|
||||
// 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 useDoxaSearch over an
|
||||
// injected `dataStore` to surface local people-group / country / language /
|
||||
// religion / affinity-bloc / cluster / region suggestions. The MAP profiles get
|
||||
// that dataStore from useMapData, which normalizes each raw API row through
|
||||
// DataSourceManager — producing the rich feature shape useDoxaSearch.buildIndex
|
||||
// reads (countryName, affinityBlock[+Label], cluster[+Label], peopleGroup[+Label],
|
||||
// wagfRegion/wagfBlock[+Label], religion[+Name], language, _raw).
|
||||
//
|
||||
// The previous hand-rolled adapter mapped only a few facet accessors, so its
|
||||
// features were MISSING the affinity/cluster/PG/region label fields → the
|
||||
// geocoder's blocs / clusters / people-groups / families / dialects aggregates
|
||||
// never populated and the dashboard dropdown read WORSE than research-map's.
|
||||
// FIX: normalize the dashboard's already-loaded raw rows through the SAME
|
||||
// DataSourceManager the map uses (reusing the rows we already fetched — no second
|
||||
// network request), so useDoxaSearch builds an IDENTICAL index and the dropdown
|
||||
// results CLONE research-map's. Keyed under the active source id + provided as
|
||||
// `searchSourceId` so the bars pass :data-source-id (mirrors research-map's
|
||||
// geocoder wiring, GeocoderComponent :data-source-id).
|
||||
// Use the SAME source the rows were loaded from (data.sourceId — honors
|
||||
// config.data.source, defaults to sourcesConfig.activeSource) so the geocoder's
|
||||
// normalization config matches the loaded rows and the bars pass the matching
|
||||
// :data-source-id. Never desyncs the row source from the search source.
|
||||
const searchSourceId = data.sourceId || sourcesConfig.activeSource
|
||||
const searchSourceCfg = sourcesConfig.sources?.[searchSourceId] || {}
|
||||
const dsm = new DataSourceManager()
|
||||
const geoFeatures = computed(() => {
|
||||
const rows = data.rows.value || []
|
||||
if (!rows.length) return []
|
||||
// normalizeData() consumes AND frees its `rawData` (sets it to null when done);
|
||||
// hand it a shallow copy so the dashboard's own row store is never mutated/cleared.
|
||||
dsm.rawData = rows.slice()
|
||||
dsm.normalizeData(searchSourceCfg)
|
||||
return dsm.normalizedData
|
||||
})
|
||||
// Shape matches what useDoxaSearch expects: dataStore.sources[id].features.
|
||||
// `features` is a getter over the computed so useDoxaSearch's reactive index
|
||||
// rebuilds when the rows load.
|
||||
const dataStore = { sources: { [searchSourceId]: { get features() { return geoFeatures.value } } } }
|
||||
|
||||
provide('selectionBus', bus)
|
||||
provide('dashboardData', data)
|
||||
provide('dashboardConfig', config)
|
||||
provide('dataStore', dataStore)
|
||||
provide('searchSourceId', searchSourceId)
|
||||
|
||||
onMounted(() => data.load())
|
||||
|
||||
return { bus, data, FACET_ACC, filteredRows, kpis, exporter, responsive }
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus (RDASH phase 1 stub).
|
||||
*
|
||||
* Two DISTINCT global reactive objects, two distinct visual languages (plan §INTERACTION):
|
||||
* - filterSet : active facet predicates (tree node / map region / chart slice).
|
||||
* NARROWS scope — panels render excluded context by FADING, never removing.
|
||||
* - selectionSet : explicit set of slugs (table checkbox / map lasso / chart pick).
|
||||
* MARKS individuals — panels render with RING/OUTLINE.
|
||||
*
|
||||
* Export priority (later phases): selectionSet (if non-empty) > filterSet result > full dataset.
|
||||
* Every panel is both publisher and subscriber. All client-side over one in-memory row store
|
||||
* keyed on slug (PK) — no per-brush round-trips.
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export function createSelectionBus() {
|
||||
const filterSet = reactive({}) // { facetKey: value | value[] } — AND-stacked facets
|
||||
const selectionSet = reactive(new Set()) // Set<slug> — explicit picks
|
||||
const subs = new Map() // event -> Set<cb>
|
||||
|
||||
function on(event, cb) {
|
||||
if (!subs.has(event)) subs.set(event, new Set())
|
||||
subs.get(event).add(cb)
|
||||
return () => subs.get(event)?.delete(cb) // returns an unsubscribe
|
||||
}
|
||||
function emit(event, payload) {
|
||||
subs.get(event)?.forEach((cb) => { try { cb(payload) } catch (_) { /* isolate subscribers */ } })
|
||||
}
|
||||
|
||||
// ── filterSet ops (scope) ──
|
||||
function setFilter(key, value) {
|
||||
if (value === null || value === undefined) delete filterSet[key]
|
||||
else filterSet[key] = value
|
||||
emit('filter', filterSet)
|
||||
}
|
||||
function clearFilters() {
|
||||
Object.keys(filterSet).forEach((k) => delete filterSet[k])
|
||||
emit('filter', filterSet)
|
||||
}
|
||||
|
||||
// ── selectionSet ops (marks) ──
|
||||
function toggleSelect(slug) {
|
||||
selectionSet.has(slug) ? selectionSet.delete(slug) : selectionSet.add(slug)
|
||||
emit('select', selectionSet)
|
||||
}
|
||||
function clearSelection() {
|
||||
selectionSet.clear()
|
||||
emit('select', selectionSet)
|
||||
}
|
||||
|
||||
return { filterSet, selectionSet, on, emit, setFilter, clearFilters, toggleSelect, clearSelection }
|
||||
}
|
||||
2
public/doxa-maps/cities-internet/index.html
generated
2
public/doxa-maps/cities-internet/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"cities-internet","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"cities-internet","scriptUrl":"./cities-internet/cities-internet.js","output":"doxa-maps/cities-internet/cities-internet.js","elements":["cities-internet-map"],"profiles":[{"profile":"connectivity-list","file":"connectivity-list.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/connectivity-list/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/cities-internet-map>"}},{"profile":"connectivity-map","file":"connectivity-map.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/connectivity-map/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/cities-internet-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/data-tables/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/data-tables/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/cities-internet-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"cities-internet","scriptUrl":"./cities-internet/cities-internet.js","output":"doxa-maps/cities-internet/cities-internet.js","elements":["cities-internet-map"],"profiles":[{"profile":"connectivity-list","file":"connectivity-list.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/connectivity-list/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/cities-internet-map>"}},{"profile":"connectivity-map","file":"connectivity-map.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/connectivity-map/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/cities-internet-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/data-tables/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/data-tables/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/cities-internet-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
2
public/doxa-maps/country-posters/index.html
generated
2
public/doxa-maps/country-posters/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"country-posters","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"country-posters","scriptUrl":"./country-posters/country-posters.js","output":"doxa-maps/country-posters/country-posters.js","elements":["country-posters-map"],"profiles":[{"profile":"countries-map","file":"countries-map.vue","description":"","element":"country-posters-map","iframeUrl":"./country-posters/countries-map/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/countries-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/country-posters-map>"}},{"profile":"country-poster-wall","file":"country-poster-wall.vue","description":"","element":"country-posters-map","iframeUrl":"./country-posters/country-poster-wall/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/country-poster-wall/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/country-posters-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"country-posters","scriptUrl":"./country-posters/country-posters.js","output":"doxa-maps/country-posters/country-posters.js","elements":["country-posters-map"],"profiles":[{"profile":"countries-map","file":"countries-map.vue","description":"","element":"country-posters-map","iframeUrl":"./country-posters/countries-map/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/countries-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/country-posters-map>"}},{"profile":"country-poster-wall","file":"country-poster-wall.vue","description":"","element":"country-posters-map","iframeUrl":"./country-posters/country-poster-wall/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/country-poster-wall/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/country-posters-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
2
public/doxa-maps/demo-map/index.html
generated
2
public/doxa-maps/demo-map/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"demo-map","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"demo-map","scriptUrl":"./demo-map/demo-map.js","output":"doxa-maps/demo-map/demo-map.js","elements":["demo-map"],"profiles":[{"profile":"demo-map","file":"demo-map.vue","description":"Simple two-tab demo map. Tab 1 colors pins by religion (shared","element":"demo-map","iframeUrl":"./demo-map/demo-map/index.html","scriptUrl":"./demo-map/demo-map.js","embed":{"iframe":"\u003ciframe src=\"./demo-map/demo-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./demo-map/demo-map.js\">\u003c/script>\n\u003cdemo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/demo-map>"},"bundle":"demo-map"}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"demo-map","scriptUrl":"./demo-map/demo-map.js","output":"doxa-maps/demo-map/demo-map.js","elements":["demo-map"],"profiles":[{"profile":"demo-map","file":"demo-map.vue","description":"Simple two-tab demo map. Tab 1 colors pins by religion (shared","element":"demo-map","iframeUrl":"./demo-map/demo-map/index.html","scriptUrl":"./demo-map/demo-map.js","embed":{"iframe":"\u003ciframe src=\"./demo-map/demo-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./demo-map/demo-map.js\">\u003c/script>\n\u003cdemo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/demo-map>"},"bundle":"demo-map"}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
2
public/doxa-maps/doxa-research-map/index.html
generated
2
public/doxa-maps/doxa-research-map/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"doxa-research-map","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"doxa-research-map","scriptUrl":"./doxa-research-map/doxa-research-map.js","output":"doxa-maps/doxa-research-map/doxa-research-map.js","elements":["doxa-research-map"],"profiles":[{"profile":"research-map","file":"research-map.vue","description":"","element":"doxa-research-map","iframeUrl":"./doxa-research-map/research-map/index.html","scriptUrl":"./doxa-research-map/doxa-research-map.js","embed":{"iframe":"\u003ciframe src=\"./doxa-research-map/research-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./doxa-research-map/doxa-research-map.js\">\u003c/script>\n\u003cdoxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/doxa-research-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"doxa-research-map","scriptUrl":"./doxa-research-map/doxa-research-map.js","output":"doxa-maps/doxa-research-map/doxa-research-map.js","elements":["doxa-research-map"],"profiles":[{"profile":"research-map","file":"research-map.vue","description":"","element":"doxa-research-map","iframeUrl":"./doxa-research-map/research-map/index.html","scriptUrl":"./doxa-research-map/doxa-research-map.js","embed":{"iframe":"\u003ciframe src=\"./doxa-research-map/research-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./doxa-research-map/doxa-research-map.js\">\u003c/script>\n\u003cdoxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/doxa-research-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
2
public/doxa-maps/doxa-simple-map/index.html
generated
2
public/doxa-maps/doxa-simple-map/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"doxa-simple-map","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"doxa-simple-map","scriptUrl":"./doxa-simple-map/doxa-simple-map.js","output":"doxa-maps/doxa-simple-map/doxa-simple-map.js","elements":["doxa-map","doxa-simple-map"],"profiles":[{"profile":"doxa-simple-map","file":"doxa-simple-map.vue","description":"","element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/index.html","scriptUrl":"./doxa-simple-map/doxa-simple-map.js","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./doxa-simple-map/doxa-simple-map.js\">\u003c/script>\n\u003cdoxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/doxa-map>"},"instances":[{"id":"prayer","title":"Prayer Map","apiBaseUrl":"https://pray.doxa.life","props":{"profile":"doxa-simple-map","instanceId":"embed-prayer","dataSource":"pray-tools","tabs":[{"id":"prayer","colorStrategy":"prayer","legend":"prayer","popup":"prayer"}]},"element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/prayer/index.html","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/prayer/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"engagement","title":"Engagement Map","apiBaseUrl":"https://pray.doxa.life","props":{"profile":"doxa-simple-map","instanceId":"embed-engagement","dataSource":"pray-tools","tabs":[{"id":"engagement","colorStrategy":"engagement","legend":"engagement","popup":"engagement"}]},"element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/engagement/index.html","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/engagement/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"adoption","title":"Adoption Map","apiBaseUrl":"https://pray.doxa.life","props":{"profile":"doxa-simple-map","instanceId":"embed-adoption","dataSource":"pray-tools","tabs":[{"id":"adoption","colorStrategy":"adoption","legend":"adoption","popup":"adoption"}]},"element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/adoption/index.html","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/adoption/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}]}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"doxa-simple-map","scriptUrl":"./doxa-simple-map/doxa-simple-map.js","output":"doxa-maps/doxa-simple-map/doxa-simple-map.js","elements":["doxa-map","doxa-simple-map"],"profiles":[{"profile":"doxa-simple-map","file":"doxa-simple-map.vue","description":"","element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/index.html","scriptUrl":"./doxa-simple-map/doxa-simple-map.js","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./doxa-simple-map/doxa-simple-map.js\">\u003c/script>\n\u003cdoxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/doxa-map>"},"instances":[{"id":"prayer","title":"Prayer Map","apiBaseUrl":"https://pray.doxa.life","props":{"profile":"doxa-simple-map","instanceId":"embed-prayer","dataSource":"pray-tools","tabs":[{"id":"prayer","colorStrategy":"prayer","legend":"prayer","popup":"prayer"}]},"element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/prayer/index.html","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/prayer/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"engagement","title":"Engagement Map","apiBaseUrl":"https://pray.doxa.life","props":{"profile":"doxa-simple-map","instanceId":"embed-engagement","dataSource":"pray-tools","tabs":[{"id":"engagement","colorStrategy":"engagement","legend":"engagement","popup":"engagement"}]},"element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/engagement/index.html","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/engagement/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"adoption","title":"Adoption Map","apiBaseUrl":"https://pray.doxa.life","props":{"profile":"doxa-simple-map","instanceId":"embed-adoption","dataSource":"pray-tools","tabs":[{"id":"adoption","colorStrategy":"adoption","legend":"adoption","popup":"adoption"}]},"element":"doxa-map","iframeUrl":"./doxa-simple-map/doxa-simple-map/adoption/index.html","embed":{"iframe":"\u003ciframe src=\"./doxa-simple-map/doxa-simple-map/adoption/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}]}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
2
public/doxa-maps/gospel-resources/index.html
generated
2
public/doxa-maps/gospel-resources/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"gospel-resources","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"gospel-resources","scriptUrl":"./gospel-resources/gospel-resources.js","output":"doxa-maps/gospel-resources/gospel-resources.js","elements":["gospel-resources-map"],"profiles":[{"profile":"charts","file":"charts.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/charts/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/charts/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/data-tables/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/data-tables/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}},{"profile":"resources-list","file":"resources-list.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/resources-list/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/resources-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}},{"profile":"resources-map","file":"resources-map.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/resources-map/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/resources-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"gospel-resources","scriptUrl":"./gospel-resources/gospel-resources.js","output":"doxa-maps/gospel-resources/gospel-resources.js","elements":["gospel-resources-map"],"profiles":[{"profile":"charts","file":"charts.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/charts/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/charts/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/data-tables/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/data-tables/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}},{"profile":"resources-list","file":"resources-list.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/resources-list/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/resources-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}},{"profile":"resources-map","file":"resources-map.vue","description":"","element":"gospel-resources-map","iframeUrl":"./gospel-resources/resources-map/index.html","scriptUrl":"./gospel-resources/gospel-resources.js","embed":{"iframe":"\u003ciframe src=\"./gospel-resources/resources-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./gospel-resources/gospel-resources.js\">\u003c/script>\n\u003cgospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/gospel-resources-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
2
public/doxa-maps/key-cities/index.html
generated
2
public/doxa-maps/key-cities/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"key-cities","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"key-cities","scriptUrl":"./key-cities/key-cities.js","output":"doxa-maps/key-cities/key-cities.js","elements":["key-cities-map"],"profiles":[{"profile":"cities-list","file":"cities-list.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/cities-list/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/cities-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"cities-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"cities-map","file":"cities-map.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/cities-map/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/cities-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"cities-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"city-poster-wall","file":"city-poster-wall.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/city-poster-wall/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/city-poster-wall/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"city-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/data-tables/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/data-tables/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"key-cities","scriptUrl":"./key-cities/key-cities.js","output":"doxa-maps/key-cities/key-cities.js","elements":["key-cities-map"],"profiles":[{"profile":"cities-list","file":"cities-list.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/cities-list/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/cities-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"cities-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"cities-map","file":"cities-map.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/cities-map/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/cities-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"cities-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"city-poster-wall","file":"city-poster-wall.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/city-poster-wall/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/city-poster-wall/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"city-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/data-tables/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/data-tables/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
27
public/doxa-maps/manifest.json
generated
27
public/doxa-maps/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "app-profiles/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 9,
|
||||
"profileCount": 20,
|
||||
"bundleCount": 10,
|
||||
"profileCount": 21,
|
||||
"bundles": [
|
||||
{
|
||||
"bundle": "cities-internet",
|
||||
|
|
@ -374,6 +374,29 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "research-dashboard",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"output": "doxa-maps/research-dashboard/research-dashboard.js",
|
||||
"elements": [
|
||||
"research-dashboard-map"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "research-dashboard",
|
||||
"file": "research-dashboard.vue",
|
||||
"description": "Coordinated-Multiple-Views research dashboard for 10/40-window UPGs (live pray.doxa.life): docked facet rail + map-as-hero + linked table/charts, brushing-and-linking over one in-memory store.",
|
||||
"element": "research-dashboard-map",
|
||||
"iframeUrl": "./research-dashboard/research-dashboard/index.html",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script src=\"./research-dashboard/research-dashboard.js\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'></research-dashboard-map>"
|
||||
},
|
||||
"bundle": "research-dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "template-bundle",
|
||||
"scriptUrl": "./template-bundle/template-bundle.js",
|
||||
|
|
|
|||
2
public/doxa-maps/my-upg-100-list/index.html
generated
2
public/doxa-maps/my-upg-100-list/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"my-upg-100-list","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"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","elements":["my-upg-100-list-map"],"profiles":[{"profile":"poster-wall","file":"poster-wall.vue","description":"","element":"my-upg-100-list-map","iframeUrl":"./my-upg-100-list/poster-wall/index.html","scriptUrl":"./my-upg-100-list/my-upg-100-list.js","embed":{"iframe":"\u003ciframe src=\"./my-upg-100-list/poster-wall/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./my-upg-100-list/my-upg-100-list.js\">\u003c/script>\n\u003cmy-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/my-upg-100-list-map>"}},{"profile":"upg-100-list","file":"upg-100-list.vue","description":"","element":"my-upg-100-list-map","iframeUrl":"./my-upg-100-list/upg-100-list/index.html","scriptUrl":"./my-upg-100-list/my-upg-100-list.js","embed":{"iframe":"\u003ciframe src=\"./my-upg-100-list/upg-100-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./my-upg-100-list/my-upg-100-list.js\">\u003c/script>\n\u003cmy-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/my-upg-100-list-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"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","elements":["my-upg-100-list-map"],"profiles":[{"profile":"poster-wall","file":"poster-wall.vue","description":"","element":"my-upg-100-list-map","iframeUrl":"./my-upg-100-list/poster-wall/index.html","scriptUrl":"./my-upg-100-list/my-upg-100-list.js","embed":{"iframe":"\u003ciframe src=\"./my-upg-100-list/poster-wall/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./my-upg-100-list/my-upg-100-list.js\">\u003c/script>\n\u003cmy-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/my-upg-100-list-map>"}},{"profile":"upg-100-list","file":"upg-100-list.vue","description":"","element":"my-upg-100-list-map","iframeUrl":"./my-upg-100-list/upg-100-list/index.html","scriptUrl":"./my-upg-100-list/my-upg-100-list.js","embed":{"iframe":"\u003ciframe src=\"./my-upg-100-list/upg-100-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./my-upg-100-list/my-upg-100-list.js\">\u003c/script>\n\u003cmy-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/my-upg-100-list-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
347
public/doxa-maps/research-dashboard/index.html
generated
Normal file
347
public/doxa-maps/research-dashboard/index.html
generated
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
--cy:#46d4ff; --vi:#b9a6f5; --gold:#ffc24b; --grn:#5ee0a0;
|
||||
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1100px 480px at 82% -12%, rgba(70,212,255,.10), transparent 60%),
|
||||
radial-gradient(820px 460px at -8% 112%, rgba(185,166,245,.10), transparent 60%),
|
||||
var(--bg);
|
||||
color: var(--ink); font-family: var(--sans); -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code { font-family: var(--mono); }
|
||||
.hidden { display: none !important; }
|
||||
.empty { color: var(--dim); font: 12px/1.5 var(--mono); padding: 18px; }
|
||||
|
||||
/* ── Shell: header strip, then sidebar|stage grid (desktop) ── */
|
||||
header.top { padding: 14px 18px 10px; border-bottom: 1px solid var(--surf2); }
|
||||
header.top h1 { font: 800 15px/1.2 var(--sans); letter-spacing: -.01em; }
|
||||
header.top h1 .ac { color: var(--cy); font-weight: 600; }
|
||||
header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut); }
|
||||
.strip { padding: 7px 18px; font: 11px/1.4 var(--mono); color: var(--mut);
|
||||
border-bottom: 1px solid var(--surf2); background: rgba(255,255,255,.012); }
|
||||
.strip .pat { color: var(--cy); } .strip .err { color: #f87171; } .strip strong { color: var(--ink); }
|
||||
|
||||
/* token bar — visible only while no token resolves */
|
||||
#tokenBar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
padding: 9px 18px; font: 12px/1.4 var(--sans); color: var(--gold);
|
||||
border-bottom: 1px solid var(--surf2); background: rgba(255,194,75,.06); }
|
||||
#tokenBar input { flex: 1 1 220px; max-width: 420px; font: 12px/1 var(--mono);
|
||||
color: var(--ink); background: var(--surf2); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 10px; }
|
||||
#tokenBar button { font: 700 11px/1 var(--mono); color: #0a0e15; cursor: pointer;
|
||||
background: linear-gradient(90deg, var(--cy), var(--vi)); border: 0;
|
||||
border-radius: 999px; padding: 8px 14px; }
|
||||
|
||||
/* ── The one-page layout ── */
|
||||
#shell { display: grid; grid-template-columns: minmax(280px, 340px) 1fr;
|
||||
height: calc(100vh - 118px); min-height: 420px; }
|
||||
|
||||
/* sidebar: THE semantic tree — navigation and architecture in one */
|
||||
#treeNav { overflow-y: auto; border-right: 1px solid var(--surf2);
|
||||
padding: 12px 10px 28px; background: rgba(255,255,255,.008); }
|
||||
.bgroup { margin-bottom: 8px; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: var(--surf); overflow: hidden; }
|
||||
.bgroup summary { list-style: none; cursor: pointer; display: flex; align-items: baseline;
|
||||
gap: 8px; padding: 10px 12px; font: 700 13px/1.2 var(--sans); }
|
||||
.bgroup summary::-webkit-details-marker { display: none; }
|
||||
.bgroup summary .bdot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
|
||||
align-self: center; background: var(--gem, var(--cy)); box-shadow: 0 0 8px var(--gem, var(--cy)); }
|
||||
.bgroup summary .bmeta { margin-left: auto; font: 10px/1.3 var(--mono); color: var(--dim); font-weight: 400; }
|
||||
.bgroup[open] summary { border-bottom: 1px solid var(--surf2); }
|
||||
|
||||
.node { display: block; width: 100%; text-align: left; cursor: pointer; color: inherit;
|
||||
background: transparent; border: 0; border-left: 2px solid transparent;
|
||||
padding: 8px 12px 8px 22px; transition: background .12s; }
|
||||
.node:hover { background: rgba(255,255,255,.03); }
|
||||
.node.active { background: color-mix(in srgb, var(--gem, var(--cy)) 10%, transparent);
|
||||
border-left-color: var(--gem, var(--cy)); }
|
||||
.node .nlabel { display: block; font: 600 12.5px/1.3 var(--sans); }
|
||||
.node .nsub { display: block; font: 10.5px/1.4 var(--mono); color: var(--mut); margin-top: 1px; }
|
||||
.node .nedit { display: block; font: 9.5px/1.4 var(--mono); color: var(--dim); margin-top: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* kind glyphs */
|
||||
.node.k-instance .nlabel::before { content: "◇ "; color: var(--grn); }
|
||||
.node.k-profile .nlabel::before { content: "● "; color: var(--vi); }
|
||||
.node.k-nested .nlabel::before { content: "◉ "; color: var(--gold); }
|
||||
|
||||
/* ── Stage: the ONE live map inside its GEM FRAME (the recurring hero theme) ── */
|
||||
#stage { display: flex; flex-direction: column; min-width: 0; padding: 12px 16px 16px; }
|
||||
.stage-head { display: flex; align-items: center; gap: 10px; padding: 0 2px 9px; }
|
||||
.stage-head .sctag { font: 800 13px/1 var(--mono); color: var(--gem, var(--cy)); }
|
||||
.stage-head .sckind { font: 8.5px/1 var(--mono); color: var(--dim); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 3px 8px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.stage-head .popout { margin-left: auto; font: 600 11px/1 var(--mono); color: var(--mut);
|
||||
text-decoration: none; border: 1px solid var(--line); border-radius: 999px; padding: 7px 12px; }
|
||||
.stage-head .popout:hover { color: var(--ink); border-color: #3a4a63; }
|
||||
/* the gem frame: colored jewel border + inner ring + soft glow in the node's gem color */
|
||||
.map-slot { flex: 1 1 0; min-height: 0; position: relative; border-radius: 12px;
|
||||
border: 1px solid var(--gem, var(--cy));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gem, var(--cy)) 45%, transparent) inset,
|
||||
0 0 26px rgba(var(--gem-rgb, 70,212,255), .16),
|
||||
0 8px 30px rgba(0,0,0,.35);
|
||||
background: #0d1622; overflow: hidden; padding: 8px; }
|
||||
.map-slot iframe { position: absolute; inset: 8px; width: calc(100% - 16px);
|
||||
height: calc(100% - 16px); border: 0; border-radius: 8px; }
|
||||
|
||||
/* ── Mobile: single column — tree on top, the map takes the FULL SCREEN below ──
|
||||
(2026-07-11: mobile map = pretty much full screen; scroll must reach it.
|
||||
100dvh = dynamic viewport height, tracks the mobile URL bar; 100vh = fallback.) */
|
||||
@media (max-width: 899px) {
|
||||
html, body { height: auto; } /* let the page actually scroll */
|
||||
#shell { display: block; height: auto; }
|
||||
#treeNav { border-right: 0; border-bottom: 1px solid var(--surf2); max-height: 44vh; }
|
||||
#stage { height: 100vh; height: 100dvh; padding: 8px; } /* full-screen map pane */
|
||||
.stage-head { padding-bottom: 6px; }
|
||||
.map-slot { padding: 4px; border-radius: 10px; } /* slimmer gem frame = max map */
|
||||
.map-slot iframe { inset: 4px; width: calc(100% - 8px); height: calc(100% - 8px); }
|
||||
.node .nedit { display: none; } /* edit paths are a desktop affordance */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>doxa-maps<span class="ac"> / research-dashboard</span></h1>
|
||||
<div class="sub">research-dashboard — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
<div id="tokenBar" class="hidden"></div>
|
||||
<div id="shell">
|
||||
<nav id="treeNav" aria-label="semantic tree"></nav>
|
||||
<main id="stage"></main>
|
||||
</div>
|
||||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"research-dashboard","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"research-dashboard","scriptUrl":"./research-dashboard/research-dashboard.js","output":"doxa-maps/research-dashboard/research-dashboard.js","elements":["research-dashboard-map"],"profiles":[{"profile":"research-dashboard","file":"research-dashboard.vue","description":"Coordinated-Multiple-Views research dashboard for 10/40-window UPGs (live pray.doxa.life): docked facet rail + map-as-hero + linked table/charts, brushing-and-linking over one in-memory store.","element":"research-dashboard-map","iframeUrl":"./research-dashboard/research-dashboard/index.html","scriptUrl":"./research-dashboard/research-dashboard.js","embed":{"iframe":"\u003ciframe src=\"./research-dashboard/research-dashboard/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./research-dashboard/research-dashboard.js\">\u003c/script>\n\u003cresearch-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/research-dashboard-map>"},"bundle":"research-dashboard"}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
* design brief (2026-07-11, the merger of all prior staging files):
|
||||
* - ONE page. No folder-hopping: the semantic tree IS the navigation.
|
||||
* - Desktop: hierarchical tree SIDEBAR on the left (bundle → profile → instance,
|
||||
* kind-badged, "where do I edit" hints) · selected map fills the stage on the right.
|
||||
* - Mobile: tree on top, map below — scroll down to the map. Native responsive,
|
||||
* no simulator toggle.
|
||||
* - Token UX: resolve ?tk= → localStorage → none. If none, show a token bar
|
||||
* (paste once, stored locally in the browser, never in files/links unless you
|
||||
* use ?tk= on purpose). Maps must Just Work after one paste.
|
||||
* - Auto-generated: whatever app-profiles exist, the tree detects and shows them.
|
||||
*
|
||||
* file:// contract: classic script, manifest inlined at build (window.FORGE.manifest),
|
||||
* fetch only as dev fallback. No import/export. The DEV-HMR block at the bottom is
|
||||
* stripped by generate-tree.mjs for built pages.
|
||||
*/
|
||||
|
||||
/* global window, document */
|
||||
const FORGE = window.FORGE || {}
|
||||
const SCOPE = FORGE.scope || 'root'
|
||||
const MODE = FORGE.mode || 'built'
|
||||
const MANIFEST_URL = FORGE.manifestUrl || './manifest.json'
|
||||
const HREF_BASE = FORGE.hrefBase || './'
|
||||
|
||||
const GEMS = ['#46d4ff', '#b9a6f5', '#ffc24b', '#5ee0a0', '#f97fb5', '#8ef0e4']
|
||||
const $ = (sel) => document.querySelector(sel)
|
||||
|
||||
let tree = null
|
||||
let flat = [] // flat list of selectable map nodes (profiles + instances)
|
||||
let activeKey = null // selected node key
|
||||
|
||||
// ── Token resolution (maintainer ladder: ?tk= → localStorage → ask once) ─────────
|
||||
const TK_STORE = 'MAPS_STAGING_TK'
|
||||
function resolveToken() {
|
||||
const qp = new URLSearchParams(location.search).get('tk')
|
||||
if (qp) { try { localStorage.setItem(TK_STORE, qp) } catch (e) {} return qp }
|
||||
try { return localStorage.getItem(TK_STORE) || '' } catch (e) { return '' }
|
||||
}
|
||||
let TK = resolveToken()
|
||||
|
||||
function withTk(href) {
|
||||
if (!TK) return href
|
||||
return href + (href.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK)
|
||||
}
|
||||
// Carry the page's ?provider= into the embedded map iframe so the engine choice propagates.
|
||||
function withProvider(href) {
|
||||
const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('provider') : null
|
||||
return p ? href + (href.includes('?') ? '&' : '?') + 'provider=' + encodeURIComponent(p) : href
|
||||
}
|
||||
|
||||
// ── Load the semantic tree (inline-first; fetch = dev fallback) ──────────────
|
||||
async function loadTree() {
|
||||
if (FORGE.manifest) return FORGE.manifest
|
||||
const res = await fetch(MANIFEST_URL, { cache: 'no-cache' })
|
||||
if (!res.ok) throw new Error('manifest ' + res.status + ' from ' + MANIFEST_URL)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const treeHref = (rel) => HREF_BASE + rel
|
||||
const hexRgb = (hex) => {
|
||||
const h = hex.replace('#', '')
|
||||
return parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16)
|
||||
}
|
||||
|
||||
function profileKind(p) {
|
||||
if (Array.isArray(p.instances) && p.instances.length) return 'parameterized'
|
||||
if (p.nested || /nested|tabs|web components/i.test(p.description || '')) return 'nested'
|
||||
return 'individual'
|
||||
}
|
||||
|
||||
// ── Build the flat selectable list + the sidebar tree ────────────────────────
|
||||
// Selectable = anything that renders as ONE map page: individual/nested profiles
|
||||
// and each instance of a parameterized profile.
|
||||
function buildFlat() {
|
||||
flat = []
|
||||
;(tree.bundles || []).forEach((b, bi) => { b.__gem = GEMS[bi % GEMS.length] })
|
||||
for (const b of tree.bundles || []) {
|
||||
for (const p of b.profiles || []) {
|
||||
const kind = profileKind(p)
|
||||
if (kind === 'parameterized') {
|
||||
for (const inst of p.instances) {
|
||||
flat.push({
|
||||
key: b.bundle + '/' + p.profile + '/' + inst.id,
|
||||
kind: 'instance', label: inst.title || inst.id,
|
||||
sub: p.profile + ' · preset "' + inst.id + '"',
|
||||
edit: b.bundle + '/' + p.profile + '.instances.json',
|
||||
href: treeHref(b.bundle + '/' + p.profile + '/' + inst.id + '/index.html'),
|
||||
bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
flat.push({
|
||||
key: b.bundle + '/' + p.profile,
|
||||
kind: kind === 'nested' ? 'nested' : 'profile',
|
||||
label: p.profile,
|
||||
sub: p.description || (kind === 'nested' ? 'embeds child web components' : 'one web component = one map'),
|
||||
edit: 'app-profiles/' + b.bundle + '/' + (p.file || p.profile + '.vue'),
|
||||
href: treeHref(b.bundle + '/' + p.profile + '/index.html'),
|
||||
bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||
}
|
||||
|
||||
// Sidebar: bundles are <details> groups (open by default), each map node a row.
|
||||
function renderSidebar() {
|
||||
const el = $('#treeNav')
|
||||
if (!el) return
|
||||
const parts = []
|
||||
const bundles = tree.bundles || []
|
||||
bundles.forEach((b, bi) => {
|
||||
const gem = b.__gem || GEMS[bi % GEMS.length]
|
||||
const rows = flat.filter((n) => n.bundle === b.bundle).map((n) => `
|
||||
<button class="node k-${n.kind}${n.key === activeKey ? ' active' : ''}" data-key="${esc(n.key)}" style="--gem:${gem}">
|
||||
<span class="nlabel">${esc(n.label)}</span>
|
||||
<span class="nsub">${esc(n.sub)}</span>
|
||||
<code class="nedit">${esc(n.edit)}</code>
|
||||
</button>`).join('')
|
||||
parts.push(`
|
||||
<details class="bgroup" open style="--gem:${gem}">
|
||||
<summary><span class="bdot"></span>${esc(b.bundle)}
|
||||
<span class="bmeta">${(b.profiles || []).length} profile${(b.profiles || []).length === 1 ? '' : 's'} · one .js</span>
|
||||
</summary>
|
||||
${rows}
|
||||
</details>`)
|
||||
})
|
||||
el.innerHTML = parts.join('') || '<p class="empty">no bundles built yet</p>'
|
||||
el.querySelectorAll('.node').forEach((btn) =>
|
||||
btn.addEventListener('click', () => select(btn.dataset.key)))
|
||||
}
|
||||
|
||||
// ── Stage: the ONE live map (iframe of the node's self-contained page) ───────
|
||||
function renderStage() {
|
||||
const st = $('#stage')
|
||||
if (!st) return
|
||||
const nd = flat.find((n) => n.key === activeKey) || flat[0]
|
||||
if (!nd) { st.innerHTML = '<p class="empty">no maps in this tree</p>'; return }
|
||||
activeKey = nd.key
|
||||
const gem = nd.gem || '#46d4ff'
|
||||
st.style.setProperty('--gem', gem)
|
||||
st.style.setProperty('--gem-rgb', hexRgb(gem))
|
||||
st.innerHTML = `
|
||||
<div class="stage-head">
|
||||
<span class="sctag"><${esc(nd.element || nd.label)}></span>
|
||||
<span class="sckind">${esc(nd.kind)}</span>
|
||||
<a class="popout" href="${esc(withProvider(withTk(nd.href)))}" target="_blank" rel="noopener">open ↗</a>
|
||||
</div>
|
||||
<div class="map-slot"><iframe allowfullscreen src="${esc(withProvider(withTk(nd.href)))}"></iframe></div>`
|
||||
// keep the sidebar highlight in sync
|
||||
document.querySelectorAll('#treeNav .node').forEach((b) =>
|
||||
b.classList.toggle('active', b.dataset.key === activeKey))
|
||||
}
|
||||
|
||||
function select(key) {
|
||||
activeKey = key
|
||||
renderStage()
|
||||
// Mobile: after picking from the tree, bring the map into view (scroll down to it).
|
||||
if (window.matchMedia('(max-width: 899px)').matches) {
|
||||
$('#stage')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Token bar: shown only while no token resolves; paste once, stored locally ─
|
||||
function renderTokenBar() {
|
||||
const bar = $('#tokenBar')
|
||||
if (!bar) return
|
||||
if (TK) { bar.classList.add('hidden'); return }
|
||||
bar.classList.remove('hidden')
|
||||
bar.innerHTML = `
|
||||
<span>maps need a Mapbox token —</span>
|
||||
<input id="tkInput" type="password" placeholder="paste pk.… (stays in this browser only)" autocomplete="off" />
|
||||
<button id="tkSave">use it</button>`
|
||||
$('#tkSave').addEventListener('click', () => {
|
||||
const v = $('#tkInput').value.trim()
|
||||
if (!v) return
|
||||
TK = v
|
||||
try { localStorage.setItem(TK_STORE, v) } catch (e) {}
|
||||
bar.classList.add('hidden')
|
||||
renderStage()
|
||||
})
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
const info = $('#frameInfo')
|
||||
try {
|
||||
tree = await loadTree()
|
||||
buildFlat()
|
||||
if (info) {
|
||||
info.innerHTML = '<span class="pat">semantic tree</span> — '
|
||||
+ (tree.bundles || []).length + ' bundle(s) · ' + flat.length + ' map(s) · '
|
||||
+ '<strong>' + esc(MODE) + '</strong>'
|
||||
}
|
||||
renderSidebar()
|
||||
renderTokenBar()
|
||||
if (flat.length) { activeKey = flat[0].key; renderStage() }
|
||||
} catch (e) {
|
||||
if (info) info.innerHTML = '<span class="err">failed to load semantic tree: ' + esc(e.message) + '</span>'
|
||||
}
|
||||
}
|
||||
|
||||
boot()
|
||||
|
||||
/* dev-hmr stripped for built pages */
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
326
public/doxa-maps/research-dashboard/research-dashboard.js
generated
Normal file
326
public/doxa-maps/research-dashboard/research-dashboard.js
generated
Normal file
File diff suppressed because one or more lines are too long
121
public/doxa-maps/research-dashboard/research-dashboard/index.html
generated
Normal file
121
public/doxa-maps/research-dashboard/research-dashboard/index.html
generated
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>research-dashboard · research-dashboard</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
|
||||
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself (design note: "Share shares THE MAP"). */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, research-dashboard-map { 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 token…</div>
|
||||
|
||||
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
|
||||
<script src="../research-dashboard.js"></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":"research-dashboard","dataSource":"pray-tools","instanceId":"research-dashboard-research-dashboard","assetsBase":"../../../assets"};
|
||||
var TOKEN_SRC = "/api/maps/token.json";
|
||||
var 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);
|
||||
});
|
||||
}
|
||||
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
|
||||
|
||||
function mount(token) {
|
||||
if (!token) { return askToken(); }
|
||||
try {
|
||||
var cfg = Object.assign({}, BASE_CONFIG, { tk: token });
|
||||
var el = document.createElement("research-dashboard-map");
|
||||
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 (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve a token endpoint and fetch it.
|
||||
var tokenUrl =
|
||||
(window.MAP_TOKEN_URL) ||
|
||||
(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '') ||
|
||||
DEFAULT_TOKEN_URL;
|
||||
|
||||
fetch(tokenUrl)
|
||||
.then(function (r) {
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) { mount(extractToken(data)); })
|
||||
.catch(function (e) {
|
||||
fail('Token fetch failed from ' + tokenUrl + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + (e && e.message ? e.message : e) + ')');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
public/doxa-maps/template-bundle/index.html
generated
2
public/doxa-maps/template-bundle/index.html
generated
|
|
@ -129,7 +129,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"template-bundle","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":9,"profileCount":20,"bundles":[{"bundle":"template-bundle","scriptUrl":"./template-bundle/template-bundle.js","output":"doxa-maps/template-bundle/template-bundle.js","elements":["template-bundle"],"profiles":[{"profile":"template-map-a","file":"template-map-a.vue","description":"Minimal example profile A — a colored placeholder, no Mapbox or data.","element":"template-bundle","iframeUrl":"./template-bundle/template-map-a/index.html","scriptUrl":"./template-bundle/template-bundle.js","embed":{"iframe":"\u003ciframe src=\"./template-bundle/template-map-a/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./template-bundle/template-bundle.js\">\u003c/script>\n\u003ctemplate-bundle profile-config='{\"profile\":\"template-map-a\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/template-bundle>"},"bundle":"template-bundle"},{"profile":"template-map-b","file":"template-map-b.vue","description":"Minimal example profile B — a second profile in the same bundle.","element":"template-bundle","iframeUrl":"./template-bundle/template-map-b/index.html","scriptUrl":"./template-bundle/template-bundle.js","embed":{"iframe":"\u003ciframe src=\"./template-bundle/template-map-b/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./template-bundle/template-bundle.js\">\u003c/script>\n\u003ctemplate-bundle profile-config='{\"profile\":\"template-map-b\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/template-bundle>"},"bundle":"template-bundle"}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"template-bundle","scriptUrl":"./template-bundle/template-bundle.js","output":"doxa-maps/template-bundle/template-bundle.js","elements":["template-bundle"],"profiles":[{"profile":"template-map-a","file":"template-map-a.vue","description":"Minimal example profile A — a colored placeholder, no Mapbox or data.","element":"template-bundle","iframeUrl":"./template-bundle/template-map-a/index.html","scriptUrl":"./template-bundle/template-bundle.js","embed":{"iframe":"\u003ciframe src=\"./template-bundle/template-map-a/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./template-bundle/template-bundle.js\">\u003c/script>\n\u003ctemplate-bundle profile-config='{\"profile\":\"template-map-a\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/template-bundle>"},"bundle":"template-bundle"},{"profile":"template-map-b","file":"template-map-b.vue","description":"Minimal example profile B — a second profile in the same bundle.","element":"template-bundle","iframeUrl":"./template-bundle/template-map-b/index.html","scriptUrl":"./template-bundle/template-bundle.js","embed":{"iframe":"\u003ciframe src=\"./template-bundle/template-map-b/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./template-bundle/template-bundle.js\">\u003c/script>\n\u003ctemplate-bundle profile-config='{\"profile\":\"template-map-b\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/template-bundle>"},"bundle":"template-bundle"}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue