Session work: strobe fix, grid layout, stacked tree legend, selection camera, chip bounding, asset inheritance, gates
This commit is contained in:
parent
79d46126de
commit
85014aad11
58 changed files with 4636 additions and 801 deletions
|
|
@ -1,26 +1,96 @@
|
|||
#!/usr/bin/env node
|
||||
// Big Picture (Cat-4) composite acceptance test — the Gate-1 chain end-to-end.
|
||||
// Exercises the REAL data flow: header emits research-filter {query,wagfRegion,wagfBlock,
|
||||
// rop1,religion} -> parent props -> grid's applyDropdownFilters -> groups-filtered {total}.
|
||||
// Runs against the live people-groups API (the same shape the grid fetches). Exit 1 on any fail.
|
||||
const API='https://pray.doxa.life/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion&lang='
|
||||
function gridFilter(recs,{query='',wagfRegion='',wagfBlock='',rop1='',religion=''}={}){
|
||||
let out=recs
|
||||
for(const [f,sel] of Object.entries({wagf_region:wagfRegion,wagf_block:wagfBlock,rop1,religion}))
|
||||
if(sel) out=out.filter(u=>(u[f]||{}).value===sel)
|
||||
if(query){const q=query.toLowerCase()
|
||||
out=out.filter(u=>String(u.name||'').toLowerCase().includes(q)||
|
||||
['country_code','rop1','religion','wagf_region','wagf_block'].some(k=>String((u[k]||{}).label||'').toLowerCase().includes(q)))}
|
||||
// Big Picture (Cat-4) composite acceptance test — the Gate-1 chain, end-to-end, against LIVE data.
|
||||
//
|
||||
// WHAT IT PROVES
|
||||
// The canonical coupling: the dashboard HEADER emits research-filter
|
||||
// {query,wagfRegion,wagfBlock,rop1,religion} -> the composite passes it DOWN as props ->
|
||||
// the REAL cap-2 grid re-filters (applyDropdownFilters + Fuse search) -> emits
|
||||
// groups-filtered {total} -> the composite reflects the count back UP into the header.
|
||||
//
|
||||
// This is a LOGIC MIRROR of the grid's applyDropdownFilters/search path, run against the SAME
|
||||
// live API the grid fetches, so a data-shape drift (e.g. a facet value rename) fails HERE.
|
||||
// A full compiled-element browser pass (real shadow DOM, real dropdown clicks) is run
|
||||
// separately via Playwright; this file is the fast, dependency-free CI gate.
|
||||
//
|
||||
// Asserts the NEGATIVE too (a filter that MUST return 0 does) and RESET (empty filter == all).
|
||||
// Exit 1 on any fail.
|
||||
|
||||
const API = 'https://pray.doxa.life/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion&lang='
|
||||
|
||||
// ── mirror of people-groups-grid.vue applyDropdownFilters + the Fuse-ish search keys ──────
|
||||
// Dropdown axes match by VALUE (u[field].value === selection) — the exact contract the header
|
||||
// dropdown -> grid prop coupling relies on. Country is excluded per Driver.
|
||||
function dropdownFilter(recs, { wagfRegion = '', wagfBlock = '', rop1 = '', religion = '' } = {}) {
|
||||
let out = recs
|
||||
for (const [f, sel] of Object.entries({ wagf_region: wagfRegion, wagf_block: wagfBlock, rop1, religion })) {
|
||||
if (sel) out = out.filter(u => (u[f] || {}).value === sel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
const r=await fetch(API); const d=await r.json(); const recs=d.posts||d.data||d; const N=recs.length
|
||||
let pass=0,fail=0
|
||||
const t=(name,ok,cnt)=>{ok?pass++:fail++;console.log(` ${ok?'✓':'✗ FAIL'} ${name} -> ${cnt}`)}
|
||||
t('no filter -> all',gridFilter(recs).length===N,gridFilter(recs).length)
|
||||
t('Region=Africa',gridFilter(recs,{wagfRegion:'africa'}).every(u=>(u.wagf_region||{}).value==='africa'),gridFilter(recs,{wagfRegion:'africa'}).length)
|
||||
t('Religion=Islam-Sunni',gridFilter(recs,{religion:'MSN'}).length>0,gridFilter(recs,{religion:'MSN'}).length)
|
||||
t('Asia+Hinduism (faceted)',gridFilter(recs,{wagfRegion:'asia',religion:'H'}).length>0,gridFilter(recs,{wagfRegion:'asia',religion:'H'}).length)
|
||||
t("search 'somali'",gridFilter(recs,{query:'somali'}).some(u=>u.name.toLowerCase().includes('somali')),gridFilter(recs,{query:'somali'}).length)
|
||||
t("search 'a' within Africa",gridFilter(recs,{wagfRegion:'africa',query:'a'}).length>0,gridFilter(recs,{wagfRegion:'africa',query:'a'}).length)
|
||||
// substring search across name + the label of each faceted axis (a conservative subset of the
|
||||
// grid's Fuse keys; the grid's fuzzy match is a SUPERSET, so any hit here is a hit there).
|
||||
function searchFilter(recs, query = '') {
|
||||
if (!query) return recs
|
||||
const q = query.toLowerCase()
|
||||
const keys = ['country_code', 'rop1', 'religion', 'wagf_region', 'wagf_block']
|
||||
return recs.filter(u =>
|
||||
String(u.name || '').toLowerCase().includes(q) ||
|
||||
keys.some(k => String((u[k] || {}).label || '').toLowerCase().includes(q)))
|
||||
}
|
||||
function gridFilter(recs, f = {}) { return searchFilter(dropdownFilter(recs, f), f.query || '') }
|
||||
|
||||
const r = await fetch(API)
|
||||
const d = await r.json()
|
||||
const recs = d.posts || d.data || d
|
||||
const N = recs.length
|
||||
|
||||
// discover a real value for each axis from the live data so the test is self-adjusting
|
||||
const firstVal = (field) => (recs.find(u => (u[field] || {}).value) || {})[field]?.value || ''
|
||||
const anyRegion = firstVal('wagf_region')
|
||||
const anyReligion = firstVal('religion')
|
||||
|
||||
let pass = 0, fail = 0
|
||||
const t = (name, ok, detail) => { ok ? pass++ : fail++; console.log(` ${ok ? '✓' : '✗ FAIL'} ${name} -> ${detail}`) }
|
||||
|
||||
// ── 1. baseline: empty filter == ALL (this is also the RESET post-condition) ───────────────
|
||||
const all = gridFilter(recs)
|
||||
t('empty filter -> all (== reset state)', all.length === N, all.length)
|
||||
|
||||
// ── 2. each single dropdown axis narrows AND every survivor really matches by VALUE ─────────
|
||||
const africa = gridFilter(recs, { wagfRegion: 'africa' })
|
||||
t('Region=africa narrows', africa.length > 0 && africa.length < N, africa.length)
|
||||
t('Region=africa every row .value===africa', africa.every(u => (u.wagf_region || {}).value === 'africa'), 'all-match')
|
||||
|
||||
const islam = gridFilter(recs, { religion: 'MSN' })
|
||||
t('Religion=MSN (Islam-Sunni) narrows', islam.length > 0 && islam.length < N, islam.length)
|
||||
t('Religion=MSN every row .value===MSN', islam.every(u => (u.religion || {}).value === 'MSN'), 'all-match')
|
||||
|
||||
// ── 3. faceted (two-axis) combo is the INTERSECTION (<= each single axis) ───────────────────
|
||||
const asia = gridFilter(recs, { wagfRegion: 'asia' })
|
||||
const hindu = gridFilter(recs, { religion: 'H' })
|
||||
const asiaHindu = gridFilter(recs, { wagfRegion: 'asia', religion: 'H' })
|
||||
t('Asia+Hinduism is a subset of Asia', asiaHindu.length > 0 && asiaHindu.length <= asia.length, `${asiaHindu.length}<=${asia.length}`)
|
||||
t('Asia+Hinduism is a subset of Hinduism', asiaHindu.length <= hindu.length, `${asiaHindu.length}<=${hindu.length}`)
|
||||
|
||||
// ── 4. search couples the header query into the grid ────────────────────────────────────────
|
||||
const somali = gridFilter(recs, { query: 'somali' })
|
||||
t("search 'somali' finds Somali groups", somali.length > 0 && somali.some(u => String(u.name).toLowerCase().includes('somali')), somali.length)
|
||||
|
||||
// ── 5. search WITHIN a dropdown selection (query + facet together) ───────────────────────────
|
||||
const africaA = gridFilter(recs, { wagfRegion: 'africa', query: 'a' })
|
||||
t("search 'a' within Africa <= Africa", africaA.length > 0 && africaA.length <= africa.length, `${africaA.length}<=${africa.length}`)
|
||||
|
||||
// ── 6. THE NEGATIVE: a nonsense query MUST return 0 (proves empty-state fires) ───────────────
|
||||
const none = gridFilter(recs, { query: 'zzzznotarealpeoplegroupxyz' })
|
||||
t('nonsense query -> 0 (empty-state path)', none.length === 0, none.length)
|
||||
|
||||
// ── 7. a nonsense DROPDOWN value MUST return 0 (proves value-alignment, not silent pass-through) ─
|
||||
const badFacet = gridFilter(recs, { wagfRegion: '__no_such_region__' })
|
||||
t('unknown region value -> 0 (value-alignment)', badFacet.length === 0, badFacet.length)
|
||||
|
||||
// ── 8. self-check the axes actually exist in live data (guards a facet rename upstream) ──────
|
||||
t('live data exposes a wagf_region facet', Boolean(anyRegion), anyRegion || '(none!)')
|
||||
t('live data exposes a religion facet', Boolean(anyReligion), anyReligion || '(none!)')
|
||||
|
||||
console.log(`\n Big Picture acceptance: ${pass} passed, ${fail} failed (${N} live records)`)
|
||||
process.exit(fail?1:0)
|
||||
process.exit(fail ? 1 : 0)
|
||||
|
|
|
|||
104
internal/atomic-public-swap.sh
Executable file
104
internal/atomic-public-swap.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env bash
|
||||
# atomic-public-swap.sh — swap a fully-built STAGE tree into the served public/ web-root atomically.
|
||||
#
|
||||
# WHY: `public/` is THREE things at once — the committed served web-root (`serve` = http.server on public/),
|
||||
# vite's outDir, AND the holder of the flock build mutex at `public/.build.lock`. A naive whole-dir swap
|
||||
# (`mv public public.old && mv staging public`) moves the lock file out from under the flock fd → silently
|
||||
# voids the concurrent-build mutex, and briefly leaves NO web-root (a reader hits a half-populated or missing
|
||||
# tree — the class of failure behind the live-down incident: an rsync --delete into a live web-root mid-churn).
|
||||
#
|
||||
# THE FIX (from RESULT-source-hygiene.md Card 3b): build fully into a STAGE dir, verify, then swap ONLY the
|
||||
# swappable parts using same-filesystem rename(2) (POSIX-atomic — a reader sees old-tree or new-tree, never a
|
||||
# half-tree):
|
||||
# 1. rename public/doxa-maps -> public/doxa-maps.old (instant; old still fully valid)
|
||||
# 2. rename STAGE/doxa-maps -> public/doxa-maps (instant; new fully valid, built+verified)
|
||||
# 3. cp STAGE/index.html -> public/index.html (root gallery page)
|
||||
# 4. rm -rf public/doxa-maps.old
|
||||
# public/assets/, public/.gitignore, and public/.build.lock are NEVER touched — the flock handle stays valid.
|
||||
#
|
||||
# This is the LOCAL form of the atomic-publish discipline; the catalog MERGE (into platform/mfe-index) has its
|
||||
# own hard-gate runbook. This script only makes the LOCAL public/ swap safe.
|
||||
#
|
||||
# USAGE:
|
||||
# internal/atomic-public-swap.sh <STAGE_DIR>
|
||||
# <STAGE_DIR> must contain a fully-built <STAGE_DIR>/doxa-maps/ tree (with manifest.json) and an
|
||||
# <STAGE_DIR>/index.html. Typically produced by a build with BUILD_STAGE_DIR pointed here.
|
||||
# Env:
|
||||
# PUBLIC_DIR (default: public) the served web-root to swap into
|
||||
# DRY_RUN=1 print the plan, make no changes
|
||||
set -euo pipefail
|
||||
|
||||
STAGE_DIR="${1:-}"
|
||||
PUBLIC_DIR="${PUBLIC_DIR:-public}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
die() { echo "❌ atomic-public-swap: $*" >&2; exit 1; }
|
||||
|
||||
[ -n "$STAGE_DIR" ] || die "usage: atomic-public-swap.sh <STAGE_DIR> (dir holding a built doxa-maps/ + index.html)"
|
||||
[ -d "$STAGE_DIR" ] || die "STAGE_DIR '$STAGE_DIR' is not a directory"
|
||||
[ -d "$PUBLIC_DIR" ] || die "PUBLIC_DIR '$PUBLIC_DIR' is not a directory (run from the studio root)"
|
||||
|
||||
STAGE_TREE="$STAGE_DIR/doxa-maps"
|
||||
STAGE_INDEX="$STAGE_DIR/index.html"
|
||||
|
||||
# --- GATE: the stage must be a COMPLETE, verified tree before we touch the live web-root ---------------------
|
||||
# (This is the same "verify is a GATE that aborts, never chained after the destructive op" lesson.)
|
||||
[ -d "$STAGE_TREE" ] || die "stage tree missing: $STAGE_TREE"
|
||||
[ -f "$STAGE_TREE/manifest.json" ] || die "stage tree has no manifest.json — refusing to swap an incomplete build"
|
||||
[ -f "$STAGE_INDEX" ] || die "stage index.html missing: $STAGE_INDEX"
|
||||
|
||||
# a complete tree has >=15 bundle dirs (the known catalog size floor); guard against a mid-churn/empty stage.
|
||||
tree_dirs=$(find "$STAGE_TREE" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
|
||||
[ "$tree_dirs" -ge 15 ] || die "stage tree has only $tree_dirs bundle dirs (<15) — looks incomplete; refusing swap"
|
||||
|
||||
# every bundle referenced in the manifest must resolve to a real .js in the stage (no dangling entry).
|
||||
missing=$(python3 - "$STAGE_TREE" <<'PY'
|
||||
import json,os,sys
|
||||
tree=sys.argv[1]
|
||||
man=json.load(open(os.path.join(tree,'manifest.json')))
|
||||
profiles=man.get('profiles',man) if isinstance(man,dict) else {}
|
||||
missing=[]
|
||||
# manifest shape: {"profiles": {name: {...embed.script...}}} — resolve each bundle's js if present
|
||||
def bundles(m):
|
||||
if isinstance(m,dict):
|
||||
for k,v in m.items():
|
||||
if isinstance(v,dict):
|
||||
yield k,v
|
||||
for name,prof in bundles(profiles):
|
||||
# each bundle folder should hold <name>/<name>.js (best-effort: check the dir has a .js)
|
||||
d=os.path.join(tree,name)
|
||||
if os.path.isdir(d) and not any(f.endswith('.js') for f in os.listdir(d)):
|
||||
missing.append(name)
|
||||
print(",".join(missing))
|
||||
PY
|
||||
)
|
||||
[ -z "$missing" ] || die "stage bundles missing a .js: $missing — refusing to swap a broken tree"
|
||||
|
||||
echo "✅ stage verified: $tree_dirs bundle dirs + manifest.json + index.html, all bundles resolve."
|
||||
|
||||
# --- GUARD: stage and public must be on the SAME filesystem for rename() to be atomic ------------------------
|
||||
stage_dev=$(stat -c '%d' "$STAGE_DIR")
|
||||
pub_dev=$(stat -c '%d' "$PUBLIC_DIR")
|
||||
[ "$stage_dev" = "$pub_dev" ] || die "STAGE_DIR and PUBLIC_DIR are on different filesystems ($stage_dev != $pub_dev) — rename() would not be atomic. Stage under the same volume as public/."
|
||||
|
||||
OLD_TREE="$PUBLIC_DIR/doxa-maps.old.$$"
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "DRY_RUN — plan (no changes):"
|
||||
echo " 1. mv $PUBLIC_DIR/doxa-maps -> $OLD_TREE"
|
||||
echo " 2. mv $STAGE_TREE -> $PUBLIC_DIR/doxa-maps"
|
||||
echo " 3. cp $STAGE_INDEX -> $PUBLIC_DIR/index.html"
|
||||
echo " 4. rm -rf $OLD_TREE"
|
||||
echo " (NEVER touched: $PUBLIC_DIR/assets/ $PUBLIC_DIR/.gitignore $PUBLIC_DIR/.build.lock)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- THE SWAP (scoped; rename-based; public/ itself and the lock never move) ---------------------------------
|
||||
if [ -d "$PUBLIC_DIR/doxa-maps" ]; then
|
||||
mv "$PUBLIC_DIR/doxa-maps" "$OLD_TREE" # 1. instant; old tree still fully valid under new name
|
||||
fi
|
||||
mv "$STAGE_TREE" "$PUBLIC_DIR/doxa-maps" # 2. instant; new tree (built+verified) becomes live
|
||||
cp "$STAGE_INDEX" "$PUBLIC_DIR/index.html" # 3. fresh root gallery page
|
||||
rm -rf "$OLD_TREE" # 4. drop the superseded tree
|
||||
|
||||
echo "🔀 atomic swap complete — $PUBLIC_DIR/doxa-maps is the freshly-built tree; assets/ + .build.lock untouched."
|
||||
|
|
@ -92,9 +92,13 @@ function readBuildConfig() {
|
|||
// QA corner widget baked into generated embed pages (the go-base plugin surface). Default true.
|
||||
// Partner embeds use the copy-embed SNIPPET with their own config, so they never get this.
|
||||
const qaWidget = cfg.qaWidget !== false
|
||||
return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget }
|
||||
// Ship the 42 MB print-resolution posters/ inside the tree? DEFAULT FALSE. The wall renders
|
||||
// from previews/, so the tree stays visually complete without them; bundling both pushed the
|
||||
// tree to 74 MB and the catalog server refused the pack. Turn on only for a fully offline drop.
|
||||
const bundlePosters = cfg.bundlePosters === true
|
||||
return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget, bundlePosters }
|
||||
} catch {
|
||||
return { name: 'doxa-maps', paths: [], embedBaseUrl: '', tokenUrl: '', token: '', assetsBaseUrl: '', apiBaseUrl: '', gatedProfiles: [], qaWidget: true }
|
||||
return { name: 'doxa-maps', paths: [], embedBaseUrl: '', tokenUrl: '', token: '', assetsBaseUrl: '', apiBaseUrl: '', gatedProfiles: [], qaWidget: true, bundlePosters: false }
|
||||
}
|
||||
}
|
||||
const DEST_CONFIG = readBuildConfig()
|
||||
|
|
@ -103,16 +107,27 @@ const TREE_NAME = DEST_CONFIG.name
|
|||
/**
|
||||
* Where a generated page should fetch shared assets from.
|
||||
*
|
||||
* The served web-root holds `assets/` NEXT TO the `<TREE_NAME>/` tree, so a page
|
||||
* nested `depth` levels below that root reaches them by climbing `depth` times.
|
||||
* An absolute `assetsBaseUrl` always wins — it is the only form that works for a
|
||||
* bundle embedded on a partner site or loaded inside the mobile app, where there
|
||||
* is no shared web-root to climb to.
|
||||
* TREE-RELATIVE, NOT WEB-ROOT-RELATIVE (BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE,
|
||||
* 2026-09-20). Assets live INSIDE the tree at `<TREE_NAME>/assets/`, so a page nested
|
||||
* `depth` levels below the web-root is `depth - 1` levels below the TREE ROOT and climbs
|
||||
* that many times. The tree is then the complete shipping unit: whoever rsyncs ONLY
|
||||
* `<TREE_NAME>/` still gets the data, and the folder resolves identically under file://,
|
||||
* a foreign subpath, Cloudflare-Drop or Railway.
|
||||
*
|
||||
* The previous form climbed `depth` times to a SIBLING `assets/` at the web-root. That is
|
||||
* correct only when the publisher also ships that sibling — ours ships the tree alone, so
|
||||
* every data fetch 404'd (all-people-groups, gospel-resources, cities-internet, upg-100).
|
||||
* A leaf could never reach a shared folder ABOVE the tree once the tree moved.
|
||||
*
|
||||
* An absolute `assetsBaseUrl` still always wins — the only form that works for a bundle
|
||||
* embedded on a partner site or loaded inside the mobile app, where there is no tree to
|
||||
* climb within (the page is on a foreign origin).
|
||||
*
|
||||
* @param {number} depth levels from the web-root: <tree>/<bundle>/<profile>/ = 3
|
||||
*/
|
||||
function assetsBaseFor(depth) {
|
||||
return DEST_CONFIG.assetsBaseUrl || `${'../'.repeat(depth)}assets`
|
||||
// depth is measured from the WEB-ROOT; the tree root is one level in, hence depth - 1.
|
||||
return DEST_CONFIG.assetsBaseUrl || `${'../'.repeat(Math.max(0, depth - 1))}assets`
|
||||
}
|
||||
// design note 2026-07-11: the staging TOP must not mention deployment orgs (doxa/GO/...). The visible
|
||||
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
|
||||
|
|
@ -163,6 +178,57 @@ function isMapLeaf(filePath) {
|
|||
catch { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a cap-4 composite (`nested-elements-mfe`) compose a child that paints a map?
|
||||
*
|
||||
* A cap-4 composite mounts its children as custom elements in ITS OWN document, so it
|
||||
* needs the map engine + token that the child would otherwise have got from its own
|
||||
* iframe leaf page. Map-ness is therefore INHERITED from the composed children.
|
||||
*
|
||||
* Resolution order, most-declared to most-forgiving:
|
||||
* 1. meta.json `composes: ["<bundle>/<profile>", …]` — the author's explicit statement of
|
||||
* what this composite nests. Each entry is resolved to a sibling .vue and scanned.
|
||||
* 2. the composite's OWN source — a composite may still touch a map engine directly.
|
||||
* 3. ANY OTHER profile in the same bundle. A cap-4 composite composes siblings from its
|
||||
* own bundle by construction (one download serves every pane), so if the bundle ships
|
||||
* a map at all, a composite in it can mount one. This is the SAFE default: a spurious
|
||||
* peer on a genuinely map-free composite costs one cached script, whereas a MISSING
|
||||
* peer costs the whole view (no canvas is ever created) — so the failure modes are
|
||||
* deliberately asymmetric.
|
||||
*
|
||||
* @param {string} dir the bundle's plugin-designs directory
|
||||
* @param {string} profileName the composite profile (e.g. 'upg-100-mfe')
|
||||
* @param {string} file the composite's own .vue filename
|
||||
*/
|
||||
function composesAMapLeaf(dir, profileName, file) {
|
||||
// 1. explicit `composes` from <profile>.meta.json
|
||||
try {
|
||||
const metaPath = join(dir, `${profileName}.meta.json`)
|
||||
if (existsSync(metaPath)) {
|
||||
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
||||
const composes = Array.isArray(meta?.composes) ? meta.composes : []
|
||||
// Entries are "<bundle>/<profile>" or a bare "<profile>"; only the last segment
|
||||
// names the .vue, and a composite composes within its own bundle.
|
||||
const named = composes
|
||||
.map((c) => String(c).split('/').pop())
|
||||
.filter(Boolean)
|
||||
if (named.length) {
|
||||
return named.some((p) => isMapLeaf(join(dir, `${p}.vue`)))
|
||||
}
|
||||
}
|
||||
} catch (_) { /* malformed meta → fall through to the source scans */ }
|
||||
|
||||
// 2. the composite's own source
|
||||
if (isMapLeaf(join(dir, file))) return true
|
||||
|
||||
// 3. any sibling profile in the same bundle
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.vue') && f !== file)
|
||||
.some((f) => isMapLeaf(join(dir, f)))
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
/** Element tag names a bundle registers, read from its index.js (order preserved). */
|
||||
function extractElements(indexJsPath) {
|
||||
if (!existsSync(indexJsPath)) return []
|
||||
|
|
@ -394,7 +460,7 @@ function bundleHtml(b, index, manifest) {
|
|||
* opts.bakedToken opt-in literal token baked from build-config (ladder rung 3)
|
||||
*/
|
||||
function mapPageHtml(opts) {
|
||||
const { title, tag, config, jsSrc, apiBaseUrl, tokenUrlExpr, bakedToken, integrity, shellTokenFree, isMap } = opts
|
||||
const { title, tag, config, jsSrc, apiBaseUrl, tokenUrlExpr, bakedToken, integrity, shellTokenFree, isMap, scrollPage } = opts
|
||||
const apiScript = apiBaseUrl
|
||||
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
|
||||
: ''
|
||||
|
|
@ -458,14 +524,34 @@ function mapPageHtml(opts) {
|
|||
<link rel="icon" href="data:,">
|
||||
${peerBlock}
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
${scrollPage
|
||||
? `/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
|
||||
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
|
||||
scrolls PAST one stacked section down to the next (BUG-DASHBOARD-CANT-SCROLL-DOWN). This is the
|
||||
OPPOSITE of the fixed-viewport map/single-element leaf below: NO position:fixed, NO inset:0,
|
||||
height:auto so the page can grow. See StackLayout.vue's "THE LAYOUT OWNS THE SCROLL" contract. */
|
||||
html { height: 100%; }
|
||||
html, body { margin: 0; background: #0a0e15; }
|
||||
body { min-height: 100%; overflow-y: auto; }
|
||||
/* MIN-HEIGHT IN VIEWPORT UNITS, NOT PERCENT (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT).
|
||||
min-height:100% here resolved against BODY's computed height, which is auto (body only has
|
||||
min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any
|
||||
element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane)
|
||||
then measured 0 and the leaf looked empty. 100dvh depends on NO ancestor, keeps the page
|
||||
growable (it is a MIN), and is correct on mobile with dynamic toolbars. */
|
||||
#slot, ${esc(tag)} { position: static; inset: auto; width: 100%; min-height: 100dvh; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }`
|
||||
: `/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, ${esc(tag)} { 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; }
|
||||
#status.err { color: #f87171; }`}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -495,6 +581,10 @@ ${apiScript}
|
|||
var TOKEN_FREE = ${JSON.stringify(tokenFree)};
|
||||
var TOKEN_SRC = ${JSON.stringify(tokenUrlExpr)};
|
||||
var BAKED_TOKEN = ${JSON.stringify(bakedToken || '')};
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
|
|
@ -618,6 +708,12 @@ function profileHtml(b, profile, tokenUrlExpr, bakedToken) {
|
|||
// Whether this leaf actually draws map tiles (computed in scanBundles → isMapLeaf). A
|
||||
// non-map leaf renders token-free with no map peer; only a real map runs the token ladder.
|
||||
isMap: profile.isMap,
|
||||
// A cap-4 composite (nested-elements-mfe) is a StackLayout SCROLL-STACK: its leaf must let the
|
||||
// DOCUMENT own the scroll (height:auto, no position:fixed) so the page grows and a reader scrolls
|
||||
// PAST one section to the next. A fixed-viewport leaf traps a 100vh dashboard and nothing scrolls
|
||||
// (BUG-DASHBOARD-CANT-SCROLL-DOWN). Cap-5 (nested-mfe-spa) stays fixed — it is a tabs shell, not a
|
||||
// scroll-stack; any scroll-stack it hosts is its OWN iframe leaf, which gets this flag itself.
|
||||
scrollPage: profile.type === 'nested-elements-mfe',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -642,6 +738,8 @@ function instanceHtml(b, profile, inst, tokenUrlExpr, bakedToken) {
|
|||
// Instance pages inherit the profile's map-ness: a preset of a non-map leaf is still a
|
||||
// non-map (token-free, no peer); a preset of a real map still runs the token ladder.
|
||||
isMap: profile.isMap,
|
||||
// A preset of a cap-4 composite scroll-stack is still a scroll page (see profileHtml).
|
||||
scrollPage: profile.type === 'nested-elements-mfe',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -759,13 +857,37 @@ function scanBundles() {
|
|||
// an *.instances.json → parameterized; else single. (detectProfileType, same as the gallery.)
|
||||
const { type: profileType } = detectProfileType(dir, profileName, tag, [], instances.length > 0)
|
||||
|
||||
// Does this leaf actually draw map tiles? Composite tiers (cap-4 nested-elements-mfe,
|
||||
// cap-5 nested-mfe-spa) are chrome that nests OTHER elements — they never paint a map
|
||||
// themselves (any real map is its own iframe leaf), so they are non-maps regardless of
|
||||
// source. Leaf tiers (single / parameterized / monolithic) are a map only if their .vue
|
||||
// references a map engine. This drives the token-gate/peer decision in mapPageHtml.
|
||||
const composite = profileType === 'nested-elements-mfe' || profileType === 'nested-mfe-spa'
|
||||
const isMap = composite ? false : isMapLeaf(join(dir, file))
|
||||
// Does this page need a map ENGINE? Not "does this .vue call mapbox itself" — the
|
||||
// question the peer/token gate actually asks is "will a map be drawn IN THIS DOCUMENT".
|
||||
// The two composite tiers answer it DIFFERENTLY, and conflating them was
|
||||
// BUG-UPG100MFE-MAP-TAB-MOUNTS-NOTHING (2026-09-20):
|
||||
//
|
||||
// cap-5 nested-mfe-spa nests children as IFRAMES. Each child is its own leaf
|
||||
// PAGE with its own peer + its own token ladder, so the
|
||||
// shell document itself draws nothing and needs nothing.
|
||||
// → non-map, always.
|
||||
//
|
||||
// cap-4 nested-elements-mfe nests children as CUSTOM ELEMENTS IN THIS SAME DOCUMENT
|
||||
// AND REALM (deliberately — one bundle download serves
|
||||
// every pane; see upg-100-mfe.vue "Why nested ELEMENTS").
|
||||
// There is no inner page to run a token ladder or load a
|
||||
// peer, so if ANY composed child paints a map, THIS page
|
||||
// must ship the engine and resolve a token. Marking it a
|
||||
// non-map emitted "NO map peer" → window.mapboxgl was
|
||||
// undefined → useMapInstance bailed at its
|
||||
// "mapboxgl is not available" guard and the map pane's
|
||||
// container was never created. The canvas was not hidden
|
||||
// or unsized; it never existed.
|
||||
//
|
||||
// So a cap-4 composite INHERITS map-ness from the siblings it composes. That is
|
||||
// derived from meta.json `composes` (and falls back to scanning the bundle's own
|
||||
// profiles), never from a per-leaf special case — the next author who composes a map
|
||||
// gets a working peer without knowing this rule exists.
|
||||
const isMap = profileType === 'nested-mfe-spa'
|
||||
? false
|
||||
: profileType === 'nested-elements-mfe'
|
||||
? composesAMapLeaf(dir, profileName, file)
|
||||
: isMapLeaf(join(dir, file))
|
||||
|
||||
return {
|
||||
profile: profileName,
|
||||
|
|
@ -922,6 +1044,95 @@ function main() {
|
|||
cpSync(src, destRel, { recursive: true })
|
||||
}
|
||||
console.log('[generate-tree] copied data/ sources → web-root/assets (people-groups, vendor, posters, tables)')
|
||||
|
||||
// ── THE TREE IS THE SHIPPING UNIT: stage the fetched assets INSIDE it ──────
|
||||
// BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE (2026-09-20). The web-root copy
|
||||
// above is a SIBLING of the tree; a publisher that rsyncs only `<TREE_NAME>/`
|
||||
// (ours does) never carries it, so every data fetch 404'd live and four elements
|
||||
// rendered a black page. Pages now resolve assets TREE-RELATIVE (assetsBaseFor),
|
||||
// so the data must live at `<TREE_NAME>/assets/` for the tree to be genuinely
|
||||
// self-contained and position-independent.
|
||||
//
|
||||
// ONLY WHAT IS FETCHED AT RUNTIME. This is an allowlist, not a mirror: the
|
||||
// web-root assets/ is 94 MB, dominated by posters (42 MB) and previews (10 MB)
|
||||
// that no leaf fetches over HTTP, plus the 16 MB people_groups.csv that nothing
|
||||
// reads (already in dropin.exclude). Copying all of it would bloat every deploy.
|
||||
// Each entry below is traceable to a literal fetch() in a .vue — add a row here
|
||||
// when an element starts fetching something new, or it will 404 in the tree.
|
||||
const treeAssets = join(DEFAULT_ROOT, 'assets')
|
||||
rmSync(treeAssets, { recursive: true, force: true })
|
||||
let staged = 0
|
||||
for (const rel of [
|
||||
// boundaries — choropleth geometry
|
||||
// gospel-resources/resources-map.vue, cities-internet/connectivity-map.vue → world-adm0
|
||||
// my-upg-100-list/upg-100-list.vue → all three (adm0 + IND/CHN adm1)
|
||||
'boundaries/world-adm0.geojson',
|
||||
'boundaries/gb-IND-ADM1.geojson',
|
||||
'boundaries/gb-CHN-ADM1.geojson',
|
||||
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
|
||||
// which no element fetches and which dropin.exclude already withholds).
|
||||
'people-groups/people-groups-slim.json',
|
||||
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
|
||||
'vendor/tabulator/tabulator.min.js',
|
||||
'vendor/tabulator/tabulator.min.css',
|
||||
]) {
|
||||
const from = join(pubAssets, rel)
|
||||
if (!existsSync(from)) { console.warn(`[generate-tree] WARN missing tree asset: assets/${rel}`); continue }
|
||||
const to = join(treeAssets, rel)
|
||||
mkdirSync(dirname(to), { recursive: true })
|
||||
cpSync(from, to)
|
||||
staged++
|
||||
}
|
||||
// tables/*.json — the data-tables + map layers fetch these by name; ship the whole
|
||||
// (small, ~14 MB total on disk but only the fetched ones matter) folder MINUS the
|
||||
// internal-only files dropin.exclude already names, so a new table works with no
|
||||
// build change. Kept as a directory copy because the fetch name is dynamic
|
||||
// (`${assetsBase}/tables/${file}.json`).
|
||||
const TABLES_SKIP = new Set(['open-doors.json', 'platform-reachability.json', 'index.json'])
|
||||
const tablesSrc = join(pubAssets, 'tables')
|
||||
if (existsSync(tablesSrc)) {
|
||||
const tablesDest = join(treeAssets, 'tables')
|
||||
mkdirSync(tablesDest, { recursive: true })
|
||||
for (const f of readdirSync(tablesSrc)) {
|
||||
if (TABLES_SKIP.has(f)) continue
|
||||
cpSync(join(tablesSrc, f), join(tablesDest, f))
|
||||
staged++
|
||||
}
|
||||
}
|
||||
// previews/ + posters/ — the poster walls fetch these PER CARD, lazily, by id
|
||||
// (`${assetsBase}/previews/<id>.webp`, `${assetsBase}/posters/<id>.webp`), so the
|
||||
// name is dynamic and the whole folder ships. They are the bulk of the tree
|
||||
// (previews ~10 MB, posters ~42 MB) but both are really fetched and dropping
|
||||
// either is a VISIBLE regression, not a saving:
|
||||
// previews → the my-upg-100-list / country-posters wall thumbnails. Without
|
||||
// them every card falls back to a text placeholder (the reported
|
||||
// "UPG-100 poster preview unavailable").
|
||||
// posters → the full-size download behind the wall's export button, which
|
||||
// fails closed with "poster not found (404)" if absent.
|
||||
// Excluded on purpose instead: assets/people_groups.csv (16 MB, fetched by
|
||||
// nothing) and the internal-only tables above.
|
||||
// POSTERS ARE OPT-IN (bundlePosters, default false). MEASURED 2026-09-20: staging both dirs
|
||||
// made the tree 74 MB, which git-packed to 57 MiB and the catalog server REJECTED outright
|
||||
// ("remote end hung up"), so nothing could publish at all. The split that resolves it comes
|
||||
// from the components themselves: PosterWall renders the wall from previews/<id>.webp
|
||||
// (line 80) and only fetches posters/<id>.webp ON DEMAND behind the export button (line 159).
|
||||
// So previews (10 MB, 378 files) are what RENDER and must ship; full posters (42 MB) are
|
||||
// print-resolution originals needed only when a user actually exports one. Shipping previews
|
||||
// keeps every element visually complete in a dropped folder — the self-contained promise —
|
||||
// while an absolute assetsBaseUrl serves the originals on demand (the same escape hatch used
|
||||
// for partner-site and mobile embeds). Set bundlePosters:true to ship them anyway for a
|
||||
// fully offline drop, accepting the size.
|
||||
const assetDirs = DEST_CONFIG.bundlePosters ? ['previews', 'posters'] : ['previews']
|
||||
for (const dirName of assetDirs) {
|
||||
const src = join(pubAssets, dirName)
|
||||
if (!existsSync(src)) { console.warn(`[generate-tree] WARN missing tree asset dir: assets/${dirName}`); continue }
|
||||
cpSync(src, join(treeAssets, dirName), { recursive: true })
|
||||
staged += readdirSync(src).length
|
||||
}
|
||||
if (!DEST_CONFIG.bundlePosters) {
|
||||
console.log('[generate-tree] posters/ NOT bundled (bundlePosters:false) — the wall renders from previews; set an absolute assetsBaseUrl to serve full posters on demand')
|
||||
}
|
||||
console.log(`[generate-tree] staged ${staged} runtime asset(s) → ${TREE_NAME}/assets (tree is self-contained)`)
|
||||
}
|
||||
|
||||
for (const dest of dests) {
|
||||
|
|
|
|||
|
|
@ -44,14 +44,41 @@ const props = defineProps({
|
|||
|
||||
// ─── Parse profile-config ─────────────────────────────────────────────────────
|
||||
|
||||
// A HOST page's assetsBase, if this loader is running INSIDE another element (a cap-4
|
||||
// composite pane). Undefined at the top level. See the inheritance note below.
|
||||
const hostAssetsBase = inject('profileConfig', null)
|
||||
|
||||
const config = computed(() => {
|
||||
if (!props.profileConfig) return null
|
||||
let parsed
|
||||
try {
|
||||
return JSON.parse(props.profileConfig)
|
||||
parsed = JSON.parse(props.profileConfig)
|
||||
} catch (e) {
|
||||
console.error('[ProfileLoader] Invalid profile-config JSON:', props.profileConfig)
|
||||
return null
|
||||
}
|
||||
// ── assetsBase INHERITANCE (BUG-UPG100MFE-ASSET-PATH-DEPTH-404S, 2026-09-20) ──
|
||||
// `assetsBase` is a PAGE-scoped value: the generator computes it once per generated
|
||||
// index.html from that page's depth below the tree root, and every consumer
|
||||
// (PosterWall, DataTable, boundary fetches) reads it off profileConfig.
|
||||
//
|
||||
// A cap-4 nested-elements composite mounts its children as custom elements in the SAME
|
||||
// document, so a child has no leaf page of its own to be told the value by. If the host
|
||||
// forgets to forward it, the child falls back to its own './assets' default — which
|
||||
// resolves against the LEAF directory, one level too deep, and every asset 404s. That is
|
||||
// exactly how 27 preview images broke on upg-100-mfe while the same images loaded fine
|
||||
// from the sibling poster-wall leaf.
|
||||
//
|
||||
// Fixing only the one host would leave the trap armed for the next author, so the
|
||||
// inheritance is enforced HERE — the single choke point every element passes through.
|
||||
// An explicit assetsBase on the child still wins; this only fills an ABSENT one, and only
|
||||
// when there is a host to inherit from (at the top level there is nothing to inherit, so
|
||||
// the consumer's own './assets' default stands unchanged).
|
||||
if (parsed && parsed.assetsBase === undefined) {
|
||||
const inherited = hostAssetsBase?.value?.assetsBase
|
||||
if (typeof inherited === 'string' && inherited) parsed.assetsBase = inherited
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
|
||||
const profileName = computed(() => config.value?.profile || null)
|
||||
|
|
@ -204,6 +231,13 @@ provide('dataStore', dataStore)
|
|||
border-radius: var(--map-radius-corners, var(--map-radius, 0px));
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* VIEWPORT-UNIT FLOOR (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). height:100% above resolves against
|
||||
the HOST — but a host sized only by min-height (the scroll-page leaf) computes height:auto, and a
|
||||
percentage of auto collapses to 0. This wrapper sits between the host and EVERY profile, so that
|
||||
one collapse zeroed every profile whose chrome is position:absolute/inset:0 (upg-100-mfe ->
|
||||
.pane-col -> .pane all measured 0 while the header still painted). A dvh floor depends on no
|
||||
ancestor, so the wrapper always has height; height:100% still wins whenever the host IS sized. */
|
||||
min-height: 100dvh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,21 @@
|
|||
* detail card opens → "Pray for them" (which carries utm_source attribution,
|
||||
* see PeopleGroupDetail.vue).
|
||||
*
|
||||
* ANCHORED TO THE MAP, NOT THE PAGE (BUG-PRAYER-TOAST-COVERS-TABLE-ROWS):
|
||||
* ProfileLoader mounts this as a SIBLING of the profile, inside .profile-loader
|
||||
* — which is position:relative with a 100dvh floor. So `bottom:26px` used to
|
||||
* resolve against the WHOLE PAGE. On a single-map profile that is the same
|
||||
* rectangle as the map, which is why it looked right for so long. On a
|
||||
* DASHBOARD (map hero on top, data table below) the two diverge badly: the
|
||||
* pill landed on the people-groups table and covered ~2 data rows at 1440x900.
|
||||
* A hint about the map must live over the map, so on mount we RE-PARENT the
|
||||
* pill into the map's own positioned container (found at runtime, the same way
|
||||
* the canvas is found — no per-profile wiring, no props, works for every
|
||||
* bundle). If no positioned container turns up we stay put, which is exactly
|
||||
* the old behaviour — a degraded anchor, never a crash.
|
||||
* It also clears the bottom-right attribution strip, which is a LICENCE
|
||||
* obligation (Mapbox ToS / OSM ODbL) and must never be covered.
|
||||
*
|
||||
* TASTEFUL BY DESIGN (no dark patterns):
|
||||
* · SELF-GATING — shows only after a map <canvas> actually exists in this
|
||||
* custom element's shadow root, so list/table profiles never see it.
|
||||
|
|
@ -22,7 +37,7 @@
|
|||
* `pinHintLabel`. Styles via useShadowStyles — a child component's <style> is
|
||||
* dropped from the custom element's shadow DOM (see WARNING.md).
|
||||
*/
|
||||
import { ref, inject, computed, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
import { ref, inject, computed, onMounted, onBeforeUnmount, getCurrentInstance, nextTick } from 'vue'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
const uiStore = inject('uiStore', null)
|
||||
|
|
@ -32,12 +47,87 @@ const label = computed(() =>
|
|||
profileConfig?.value?.pinHintLabel || 'Tap a red dot — someone still needs prayer')
|
||||
|
||||
const visible = ref(false)
|
||||
const hintEl = ref(null)
|
||||
let watchTimer = null
|
||||
let movedTo = null // the element we re-parented into, so we can put ourselves back
|
||||
let attribRO = null // observes the attribution control (it resizes on its own, see below)
|
||||
let attribEl = null
|
||||
|
||||
// The reader closes it; nothing else does — no timer, and no dismissal on the first pan,
|
||||
// so the invitation is still there when they come back to the map.
|
||||
function dismiss() { visible.value = false }
|
||||
|
||||
/**
|
||||
* From the map <canvas>, walk up to the element the pill should live inside: the
|
||||
* nearest ancestor that ESTABLISHES A CONTAINING BLOCK for absolute positioning
|
||||
* (position other than static). That is the map's own box — .rd-mapwrap on the
|
||||
* dashboard, the map shell on a single-map profile.
|
||||
*
|
||||
* Why walk up from the canvas instead of matching a class: a class list would be a
|
||||
* hardcoded registry of every profile's private markup, and this component lives in
|
||||
* library/ where it must stay parameterized. The containing block is a STRUCTURAL
|
||||
* fact that is true in every bundle, so it needs no per-profile knowledge.
|
||||
*
|
||||
* Stops at the shadow root / the loader wrapper — re-parenting into .profile-loader
|
||||
* would just reproduce the bug we are fixing.
|
||||
*/
|
||||
function findMapBox(canvas, root) {
|
||||
let el = canvas?.parentElement
|
||||
for (let hops = 0; el && hops < 12; hops++, el = el.parentElement) {
|
||||
if (el === root || el.classList?.contains('profile-loader')) return null
|
||||
let pos = ''
|
||||
try { pos = getComputedStyle(el).position } catch (_) { /* detached */ }
|
||||
if (pos && pos !== 'static') {
|
||||
// Guard against a container so short the pill would overflow it.
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.height >= 120 && r.width >= 160) return el
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise the pill clear of the map's attribution strip. Mapbox swaps between an expanded
|
||||
* credit line and a compact round "i" button depending on map WIDTH, and the compact
|
||||
* button is TALLER (24px vs 20px) — so a fixed offset that clears the strip at 1440px
|
||||
* can still clip the button at 1024px. Measure the real control instead of assuming.
|
||||
* Attribution is a LICENCE obligation; this only ever READS it.
|
||||
*/
|
||||
function syncAttribClearance(root) {
|
||||
const el = hintEl.value
|
||||
if (!el || !root?.querySelector) return
|
||||
let h = 0
|
||||
try {
|
||||
// TWO attribution flavours ship in this repo and the pill must clear EITHER:
|
||||
// .mapboxgl-ctrl-attrib — Mapbox's own control (useMapInstance, attributionControl:true)
|
||||
// .doxa-attribution — our own credit line (useMapAttribution.js) on the profiles
|
||||
// that construct with attributionControl:false
|
||||
// Both are licence-required credit, so both are obstacles, never things to cover.
|
||||
const a = root.querySelector(
|
||||
'.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib, .mapboxgl-ctrl-bottom-right .doxa-attribution, .doxa-attribution')
|
||||
if (a) {
|
||||
// Measure the strip the control OCCUPIES from the bottom of the box we are anchored
|
||||
// in, not the control's own height: Mapbox adds a margin inside
|
||||
// .mapboxgl-ctrl-bottom-right, so height alone undercounts the reserved space.
|
||||
const cr = a.getBoundingClientRect()
|
||||
const host = el.parentElement
|
||||
const hr = host ? host.getBoundingClientRect() : null
|
||||
h = hr ? Math.max(cr.height || 0, hr.bottom - cr.top) : (cr.height || 0)
|
||||
// Mapbox collapses the credit to the compact "i" button AFTER first paint, and that
|
||||
// swap changes the CONTROL's height without changing the map's — so observe the
|
||||
// control itself or we stay pinned to the stale expanded measurement.
|
||||
if (attribEl !== a && typeof ResizeObserver !== 'undefined') {
|
||||
if (attribRO) { try { attribRO.disconnect() } catch (_) {} }
|
||||
attribEl = a
|
||||
attribRO = new ResizeObserver(() => syncAttribClearance(root))
|
||||
attribRO.observe(a)
|
||||
}
|
||||
}
|
||||
} catch (_) { /* not painted yet */ }
|
||||
const clear = Math.min(Math.max(Math.ceil(h) || 20, 20), 72)
|
||||
el.style.setProperty('--pin-hint-attrib-clear', clear + 'px')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Find this custom element's shadow root and wait (max ~12s) for a map canvas.
|
||||
const inst = getCurrentInstance()
|
||||
|
|
@ -50,6 +140,15 @@ onMounted(() => {
|
|||
if (canvas) {
|
||||
clearInterval(watchTimer); watchTimer = null
|
||||
visible.value = true
|
||||
// Re-parent AFTER the v-if has rendered the pill, so hintEl exists.
|
||||
nextTick(() => {
|
||||
const box = findMapBox(canvas, root)
|
||||
const el = hintEl.value
|
||||
if (box && el && el.parentElement !== box) {
|
||||
try { box.appendChild(el); movedTo = box } catch (_) { /* keep the page anchor */ }
|
||||
}
|
||||
syncAttribClearance(root)
|
||||
})
|
||||
} else if (tries > 40) { // ~12s: no canvas → not a map profile → never show
|
||||
clearInterval(watchTimer); watchTimer = null
|
||||
}
|
||||
|
|
@ -57,11 +156,25 @@ onMounted(() => {
|
|||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (watchTimer) clearInterval(watchTimer)
|
||||
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
|
||||
// We appended our own element into a node Vue does not own. Vue removes the element
|
||||
// it created, but only from where IT thinks it is — so detach explicitly to be certain
|
||||
// no orphan pill is left behind in the map container on profile teardown.
|
||||
if (movedTo && hintEl.value?.parentElement === movedTo) {
|
||||
try { movedTo.removeChild(hintEl.value) } catch (_) {}
|
||||
}
|
||||
movedTo = null
|
||||
})
|
||||
|
||||
useShadowStyles(`
|
||||
.pin-hint { position: absolute; left: 50%; bottom: 26px; transform: translateX(-50%);
|
||||
/* bottom: clears the attribution strip along the map's bottom edge. That credit line is a
|
||||
LICENCE obligation (Mapbox ToS / OSM ODbL) and must never be covered — so the pill sits
|
||||
ABOVE it rather than on it. max-width keeps the pill inside narrow map panels instead of
|
||||
bleeding past their edges at 1024px and below. */
|
||||
.pin-hint { position: absolute; left: 50%;
|
||||
bottom: calc(var(--pin-hint-attrib-clear, 20px) + 12px); transform: translateX(-50%);
|
||||
z-index: 2147482000; pointer-events: none;
|
||||
max-width: calc(100% - 24px); box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
animation: pinhint-in .5s ease both; }
|
||||
.pin-hint-pill { pointer-events: auto; display: inline-flex; align-items: center; gap: 9px;
|
||||
|
|
@ -90,7 +203,7 @@ useShadowStyles(`
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="pin-hint" :class="{ dark: isDark }" role="status">
|
||||
<div v-if="visible" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
|
||||
<span class="pin-hint-pill">
|
||||
<span class="pin-hint-dot" aria-hidden="true"></span>
|
||||
{{ label }}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@
|
|||
ZoomInButton.vue — Increments the map zoom level by one step.
|
||||
|
||||
Props:
|
||||
map — the live mapboxgl.Map instance (Object, required)
|
||||
isDark — theme flag forwarded from parent
|
||||
map — the live mapboxgl.Map instance (Object, required)
|
||||
isDark — theme flag forwarded from parent
|
||||
disabled — grey out + block the click when the camera is already at maxZoom.
|
||||
Declared EXPLICITLY (rather than relying on attribute fallthrough
|
||||
reaching MapControlButton's own prop) so the contract is visible to
|
||||
callers and survives any future inheritAttrs change.
|
||||
-->
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
|
@ -12,17 +16,21 @@ import MapControlButton from './MapControlButton.vue'
|
|||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
map: { type: Object, default: null },
|
||||
isDark: { type: Boolean, default: false }
|
||||
map: { type: Object, default: null },
|
||||
isDark: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
function zoomIn() {
|
||||
if (props.disabled) return
|
||||
// Mapbox's discrete one-step zoom (a single bounded easing), NOT a continuous
|
||||
// per-frame camera loop — keep it that way (see BUG-RESIZE-STROBE).
|
||||
props.map?.zoomIn()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MapControlButton :is-dark="isDark" :title="t('buttons.zoomIn')" @click="zoomIn">
|
||||
<MapControlButton :is-dark="isDark" :disabled="disabled" :title="t('buttons.zoomIn')" @click="zoomIn">
|
||||
<span style="font-size:20px;line-height:1;font-weight:600;">+</span>
|
||||
</MapControlButton>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@
|
|||
ZoomOutButton.vue — Decrements the map zoom level by one step.
|
||||
|
||||
Props:
|
||||
map — the live mapboxgl.Map instance (Object, required)
|
||||
isDark — theme flag forwarded from parent
|
||||
map — the live mapboxgl.Map instance (Object, required)
|
||||
isDark — theme flag forwarded from parent
|
||||
disabled — grey out + block the click when the camera is already at the minZoom
|
||||
floor. useMapInstance computes that floor from the container aspect, so
|
||||
on a short panel zoomOut() would otherwise be a silent no-op; disabling
|
||||
makes "at the limit" legible instead of looking like a dead button.
|
||||
-->
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
|
@ -12,17 +16,20 @@ import MapControlButton from './MapControlButton.vue'
|
|||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
map: { type: Object, default: null },
|
||||
isDark: { type: Boolean, default: false }
|
||||
map: { type: Object, default: null },
|
||||
isDark: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
function zoomOut() {
|
||||
if (props.disabled) return
|
||||
// Discrete one-step zoom — see the note in ZoomInButton.vue.
|
||||
props.map?.zoomOut()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MapControlButton :is-dark="isDark" :title="t('buttons.zoomOut')" @click="zoomOut">
|
||||
<MapControlButton :is-dark="isDark" :disabled="disabled" :title="t('buttons.zoomOut')" @click="zoomOut">
|
||||
<span style="font-size:22px;line-height:1;font-weight:600;">−</span>
|
||||
</MapControlButton>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -15,11 +15,16 @@ each self-styles inside a standalone custom-element shadow root.
|
|||
Import via the `@map` alias (resolves to `library/`):
|
||||
|
||||
```js
|
||||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import Fuse from '@map/components/search/fuzzy.js'
|
||||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import SearchFilterHeader from '@map/components/search/SearchFilterHeader.vue'
|
||||
import Fuse from '@map/components/search/fuzzy.js'
|
||||
```
|
||||
|
||||
- **research-dashboard** `plugin-designs/research-dashboard/` — bakes `SearchFilterHeader` in as
|
||||
header-1 (search) + header-2 (dropdowns) via the thin adapter
|
||||
`src/components/dashboards/DashboardResearchFilters.vue` (couples it to the selection bus).
|
||||
|
||||
---
|
||||
|
||||
## `SearchBar.vue`
|
||||
|
|
@ -70,6 +75,47 @@ Region / Block / People-Group / Religion in the grid; Region / Status in the das
|
|||
|
||||
---
|
||||
|
||||
## `SearchFilterHeader.vue`
|
||||
|
||||
The DX-life research-page filter header, cloned as ONE parameterized element: header-1 (a
|
||||
`SearchBar`) + header-2 (an "expand filters" row of `FilterDropdown`s + optional boolean
|
||||
toggle-pills + a removable active-chip strip). Pure UI/interaction — no bus, no fetch — so the
|
||||
consumer supplies option lists + selected values and reacts to the emits. The dropdown row is a
|
||||
SINGLE row that **never wraps** — it horizontal-scrolls on overflow (card
|
||||
`BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW`).
|
||||
|
||||
| Prop | Type | Default | Purpose |
|
||||
|-----------------|---------------|---------|----------------------------------------------------------------|
|
||||
| `search` | String | `''` | Search-bar value (supports `v-model:search`). |
|
||||
| `filters` | Array | `[]` | Dropdown axes: `[{ name, label, options, value?, placeholder? }]`. |
|
||||
| `toggles` | Array | `[]` | Boolean pills: `[{ name, label, active? }]`. |
|
||||
| `activeChips` | Array \| null | `null` | Removable chips `[{ name, label }]`; omit to auto-derive. |
|
||||
| `searchPlaceholder` | String | `'Search…'` | header-1 placeholder. |
|
||||
| `searchMaxWidth`| String | `'520px'` | header-1 search box max-width. |
|
||||
| `showFilters` | Bool \| String| `true` | Render the header-2 filter row at all. |
|
||||
| `startExpanded` | Boolean | `false` | Show the dropdown row without a first click. |
|
||||
| `collapsible` | Boolean | `true` | Show the Show/Hide-Filters toggle; `false` = always open. |
|
||||
| `labels` | Object | `{}` | i18n overrides (show/hide_filters, clear_all, type_to_search…). |
|
||||
|
||||
| Event | Payload | When |
|
||||
|-----------------|------------------------------------|-------------------------------|
|
||||
| `update:search` | `String` | Search changed (`v-model`). |
|
||||
| `search` | `{ value }` | Every keystroke. |
|
||||
| `filter-change` | `{ name, value, label, type }` | A dropdown option was picked. |
|
||||
| `filter-clear` | `{ name }` | A dropdown selection cleared. |
|
||||
| `toggle` | `{ name, active }` | A toggle-pill flipped. |
|
||||
| `remove-filter` | `{ name }` | An active chip's ✕ clicked. |
|
||||
| `clear-all` | — | "Clear all" clicked. |
|
||||
|
||||
```vue
|
||||
<SearchFilterHeader
|
||||
v-model:search="term" :filters="filters" :active-chips="chips" start-expanded
|
||||
@search="onSearch" @filter-change="onPick" @filter-clear="onClear"
|
||||
@remove-filter="onRemove" @clear-all="onClearAll" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `fuzzy.js`
|
||||
|
||||
A zero-dependency Fuse.js-shaped matcher (so no npm dep is added to the shared
|
||||
|
|
|
|||
235
library/components/search/SearchFilterHeader.vue
Normal file
235
library/components/search/SearchFilterHeader.vue
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<script setup>
|
||||
/**
|
||||
* SearchFilterHeader.vue — SHARED (@map/components/search/SearchFilterHeader.vue).
|
||||
*
|
||||
* The DX-life research-page filter header, cloned as ONE parameterized, reusable element:
|
||||
* header-1 = a search bar (the SearchBar primitive)
|
||||
* header-2 = an "expand filters" dropdown SET (a row of FilterDropdown primitives) plus
|
||||
* optional boolean toggle-buttons (Adopted / Engaged / Exact …) and a live
|
||||
* removable active-filter chip strip.
|
||||
*
|
||||
* Cloned (COPY-ONLY, never imported) from the DX-life research page's UupgsList.vue filter
|
||||
* block (root-dx-life/.../components/public/UupgsList.vue) — the search box + "Show/Hide
|
||||
* Filters" toggle + the FilterDropdown panel + the toggle-button panel + the active-chips
|
||||
* strip. It reuses the already-shared SearchBar + FilterDropdown primitives so there is ONE
|
||||
* source of truth for the controls; this element only ADDS the header composition + the
|
||||
* expand/collapse + the one-row horizontal-scroll layout.
|
||||
*
|
||||
* FULLY props-driven / emit-based / framework-agnostic (no Nuxt, no i18n composable, no
|
||||
* fetch, no bus, no plugin hardcoding). The consumer supplies the option lists + selected
|
||||
* values and reacts to the emits — so the SAME header drives the people-groups-grid, the
|
||||
* big-picture, AND the research-dashboard. The dashboard couples it to its selection bus via
|
||||
* a thin adapter (DashboardResearchFilters.vue); the grid drives its in-memory Fuse filter;
|
||||
* big-picture forwards to its cap-2 grid. None of that lives here.
|
||||
*
|
||||
* ── ★ ONE-ROW HORIZONTAL SCROLL (card BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW) ──
|
||||
* The dropdown set is a SINGLE flex row that NEVER wraps — on overflow it scrolls
|
||||
* horizontally (overflow-x:auto; flex-wrap:nowrap). Each FilterDropdown is flex:0 0 auto so
|
||||
* it keeps its width instead of shrinking into a second line.
|
||||
*
|
||||
* ── API ────────────────────────────────────────────────────────────────────────
|
||||
* Props:
|
||||
* search String '' — the search-bar value (supports `v-model:search`).
|
||||
* filters Array [] — the dropdown axes, ONE object per dropdown:
|
||||
* { name, label, options:[{value,label,count?}], value?, placeholder? }
|
||||
* toggles Array [] — boolean toggle-buttons: { name, label, active? }
|
||||
* activeChips Array [] — removable active-filter chips: { name, label }
|
||||
* (when omitted the header derives chips from filters+toggles).
|
||||
* searchPlaceholder String 'Search…'
|
||||
* searchMaxWidth String '520px' — max-width of the header-1 search box.
|
||||
* showSearch Boolean|String true — render the header-1 search row at all. Set false when
|
||||
* the consumer hosts the search bar elsewhere (e.g. the research-
|
||||
* dashboard lifts it into the topbar) so this header is FILTERS-ONLY.
|
||||
* showFilters Boolean|String true — render the header-2 filter row at all.
|
||||
* startExpanded Boolean false — show the dropdown row without a first click.
|
||||
* collapsible Boolean true — show the Show/Hide-Filters toggle; false = always open.
|
||||
* labels Object {} — i18n overrides: { show_filters, hide_filters, clear_all,
|
||||
* type_to_search, filters }
|
||||
* Emits:
|
||||
* update:search String — new search value (enables v-model:search).
|
||||
* search { value } — on every keystroke (raw string).
|
||||
* filter-change { name, value, label, type } — a dropdown option was picked.
|
||||
* filter-clear { name } — a dropdown selection was cleared.
|
||||
* toggle { name, active } — a boolean toggle flipped.
|
||||
* remove-filter { name } — an active chip's ✕ was clicked.
|
||||
* clear-all — the "Clear all" chip was clicked.
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import SearchBar from './SearchBar.vue'
|
||||
import FilterDropdown from './FilterDropdown.vue'
|
||||
|
||||
const props = defineProps({
|
||||
search: { type: String, default: '' },
|
||||
filters: { type: Array, default: () => [] },
|
||||
toggles: { type: Array, default: () => [] },
|
||||
activeChips: { type: Array, default: null },
|
||||
searchPlaceholder: { type: String, default: 'Search…' },
|
||||
searchMaxWidth: { type: String, default: '520px' },
|
||||
showSearch: { type: [Boolean, String], default: true },
|
||||
showFilters: { type: [Boolean, String], default: true },
|
||||
startExpanded: { type: Boolean, default: false },
|
||||
collapsible: { type: Boolean, default: true },
|
||||
labels: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:search', 'search', 'filter-change', 'filter-clear', 'toggle', 'remove-filter', 'clear-all',
|
||||
])
|
||||
|
||||
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)
|
||||
const useSearch = computed(() => truthy(props.showSearch))
|
||||
const useFilters = computed(() => truthy(props.showFilters))
|
||||
|
||||
const L = computed(() => ({
|
||||
show_filters: 'Show Filters',
|
||||
hide_filters: 'Hide Filters',
|
||||
clear_all: 'Clear All',
|
||||
type_to_search: 'Type to search...',
|
||||
filters: 'Filters',
|
||||
...props.labels,
|
||||
}))
|
||||
|
||||
// The dropdown row is collapsed by default (DX-life behaviour) unless startExpanded /
|
||||
// non-collapsible — then it's always open.
|
||||
const expanded = ref(props.startExpanded || !props.collapsible)
|
||||
function toggleExpanded() { expanded.value = !expanded.value }
|
||||
// When the search row is hidden the Show/Hide-Filters toggle is gone too, so the dropdown row
|
||||
// can only be reached if it stays open — force it open in that case.
|
||||
const isOpen = computed(() => !props.collapsible || !useSearch.value || expanded.value)
|
||||
|
||||
// Chips: consumer-supplied, else derived from the selected dropdowns + active toggles so a
|
||||
// standalone consumer gets a working removable chip strip for free.
|
||||
const chips = computed(() => {
|
||||
if (Array.isArray(props.activeChips)) return props.activeChips
|
||||
const out = []
|
||||
for (const f of props.filters) {
|
||||
if (!f.value) continue
|
||||
const opt = (f.options || []).find(o => o.value === f.value)
|
||||
out.push({ name: f.name, label: opt ? opt.label : f.value })
|
||||
}
|
||||
for (const t of props.toggles) {
|
||||
if (t.active) out.push({ name: t.name, label: t.label })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function onSearch(payload) {
|
||||
const value = payload?.value ?? ''
|
||||
emit('update:search', value)
|
||||
emit('search', { value })
|
||||
}
|
||||
function onFilterChange(detail) { emit('filter-change', detail) }
|
||||
function onFilterClear(detail) { emit('filter-clear', detail) }
|
||||
function onToggle(t) { emit('toggle', { name: t.name, active: !t.active }) }
|
||||
function onRemoveChip(name) { emit('remove-filter', { name }) }
|
||||
function onClearAll() { emit('clear-all') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sfh">
|
||||
<!-- header-1: the search bar (omitted when the consumer hosts search elsewhere) -->
|
||||
<div v-if="useSearch" class="sfh__search-row">
|
||||
<SearchBar
|
||||
:model-value="search"
|
||||
:placeholder="searchPlaceholder"
|
||||
:aria-label="searchPlaceholder"
|
||||
:max-width="searchMaxWidth"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="useFilters && collapsible"
|
||||
class="sfh__toggle"
|
||||
type="button"
|
||||
:aria-expanded="isOpen"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
{{ isOpen ? L.hide_filters : L.show_filters }}
|
||||
<svg class="sfh__toggle-chev" width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- header-2: the expand-filters dropdown SET — ONE row, horizontal-scroll on overflow -->
|
||||
<div v-if="useFilters && isOpen" class="sfh__filters-row" role="group" :aria-label="L.filters">
|
||||
<FilterDropdown
|
||||
v-for="f in filters"
|
||||
:key="f.name"
|
||||
class="sfh__dd"
|
||||
:label="f.label"
|
||||
:name="f.name"
|
||||
:options="f.options || []"
|
||||
:value="f.value || ''"
|
||||
:placeholder="f.placeholder || L.type_to_search"
|
||||
@filter-change="onFilterChange"
|
||||
@filter-clear="onFilterClear"
|
||||
/>
|
||||
<button
|
||||
v-for="t in toggles"
|
||||
:key="t.name"
|
||||
class="sfh__pill"
|
||||
type="button"
|
||||
:data-active="t.active ? '' : undefined"
|
||||
@click="onToggle(t)"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- active-filter chips (removable) -->
|
||||
<div v-if="useFilters && chips.length" class="sfh__chips">
|
||||
<span v-for="c in chips" :key="c.name" class="sfh__chip">
|
||||
{{ c.label }}
|
||||
<button class="sfh__chip-x" type="button" :aria-label="`Remove ${c.label}`" @click="onRemoveChip(c.name)">×</button>
|
||||
</span>
|
||||
<button class="sfh__clear" type="button" @click="onClearAll">{{ L.clear_all }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Self-contained + scoped so the header self-styles inside a standalone custom-element
|
||||
shadow root (mirrors the SearchBar / FilterDropdown primitives). CUBE custom-props from the
|
||||
DX-life _filters.scss are resolved to concrete values. */
|
||||
.sfh {
|
||||
--brand: #2563eb; --ink: #0f172a; --mut: #64748b; --line: #e2e8f0; --card: #fff;
|
||||
display: flex; flex-direction: column; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; color: var(--ink);
|
||||
}
|
||||
.sfh__search-row { display: flex; align-items: center; gap: 12px; }
|
||||
.sfh__toggle {
|
||||
flex: 0 0 auto; background: none; border: none; cursor: pointer; font-size: 0.875rem;
|
||||
color: var(--brand); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 2px;
|
||||
}
|
||||
.sfh__toggle-chev { transition: transform 200ms ease; }
|
||||
.sfh__toggle[aria-expanded="true"] .sfh__toggle-chev { transform: rotate(180deg); }
|
||||
|
||||
/* ★ ONE ROW, NEVER WRAP — horizontal-scroll on overflow. */
|
||||
.sfh__filters-row {
|
||||
display: flex; flex-wrap: nowrap; align-items: center; gap: 10px;
|
||||
overflow-x: auto; overflow-y: visible; padding-bottom: 4px;
|
||||
scrollbar-width: thin; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* keep each control at its width instead of collapsing into a 2nd line */
|
||||
.sfh__dd { flex: 0 0 auto; }
|
||||
/* override the primitive's mobile "flex:1 1 100%" so it never full-widths and wraps */
|
||||
@media (max-width: 576px) { .sfh__dd { flex: 0 0 auto; max-width: 240px; } }
|
||||
.sfh__filters-row::-webkit-scrollbar { height: 8px; }
|
||||
.sfh__filters-row::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 8px; }
|
||||
.sfh__filters-row::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
.sfh__pill {
|
||||
flex: 0 0 auto; padding: 6px 12px; cursor: pointer; font-size: 0.875rem; color: inherit;
|
||||
background: var(--card); border: 1px solid #cbd5e1; border-radius: 8px; white-space: nowrap;
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.sfh__pill:hover { border-color: var(--brand); }
|
||||
.sfh__pill[data-active] { background-color: rgba(37, 99, 235, 0.08); border-color: var(--brand); color: var(--brand); }
|
||||
|
||||
.sfh__chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.sfh__chip {
|
||||
display: inline-flex; gap: 6px; align-items: center; background-color: rgba(37, 99, 235, 0.1);
|
||||
color: var(--brand); border-radius: 100px; padding: 4px 12px; font-size: 0.85rem;
|
||||
}
|
||||
.sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.1em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center; }
|
||||
.sfh__chip-x:hover { opacity: 0.7; }
|
||||
.sfh__clear { background: none; border: none; cursor: pointer; font-size: 0.875rem; color: var(--brand); text-decoration: underline; text-underline-offset: 4px; }
|
||||
</style>
|
||||
|
|
@ -277,7 +277,12 @@ export function useMapFly(options = {}) {
|
|||
if (!map || !bounds) return;
|
||||
// maxZoom defaults to 4 (continent-scale framing). Caller can override.
|
||||
const maxZoom = options.maxZoom ?? 4;
|
||||
map.fitBounds(bounds, { padding, maxZoom, ...FLY_CONFIG });
|
||||
// `options` is spread LAST so a caller can override any animation field — notably
|
||||
// `duration: 0`, which an accessibility path needs in order to JUMP instead of ease
|
||||
// for prefers-reduced-motion users. Previously only `maxZoom` was read and every
|
||||
// other caller option was silently dropped, so a reduced-motion caller still got
|
||||
// FLY_CONFIG's easing. Passing no options is byte-for-byte the previous behaviour.
|
||||
map.fitBounds(bounds, { padding, maxZoom, ...FLY_CONFIG, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -263,6 +263,8 @@ export function useMapInstance({
|
|||
const isMapReady = ref(false)
|
||||
// Holds the dynamic-minZoom resize handler so destroy() can remove it.
|
||||
let _onResize = null
|
||||
// Cancels the debounced zoom-floor snap (anti-strobe) so destroy() leaves no pending timer.
|
||||
let _clearSnap = null
|
||||
// Holds the gray side-edge touch handlers so destroy() can remove them.
|
||||
let _detachEdgeBorders = null
|
||||
|
||||
|
|
@ -289,8 +291,15 @@ export function useMapInstance({
|
|||
|
||||
// ─── API Key validation (Mapbox only — MapLibre + OpenFreeMap need no token) ──
|
||||
if (!isMaplibre) {
|
||||
// Reusable fallback: a host that can't thread a per-instance token (e.g. a BAKED composite that
|
||||
// mounts a map component directly, with no ProfileLoader / profile-config wrapper) may expose the
|
||||
// token once as `window.MAP_TOKEN`. Any map on the page then finds it. Per-instance accessToken
|
||||
// still wins; this only fills the gap when none was passed.
|
||||
if (!accessToken && typeof window !== 'undefined' && typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) {
|
||||
accessToken = window.MAP_TOKEN
|
||||
}
|
||||
if (!accessToken) {
|
||||
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON.')
|
||||
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON or set window.MAP_TOKEN.')
|
||||
return
|
||||
}
|
||||
if (!accessToken.startsWith('pk.')) {
|
||||
|
|
@ -441,13 +450,45 @@ export function useMapInstance({
|
|||
// two worlds at once, but low enough on tall/portrait phones to see the whole
|
||||
// world. Portrait↔landscape flips which term binds; if the new floor is above
|
||||
// the current zoom, snap up so the camera stays within the floor.
|
||||
// A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): this handler runs on EVERY Mapbox/MapLibre
|
||||
// 'resize' event, and map.resize() itself emits one. When a container is being dragged
|
||||
// (a resizable dashboard section, a window-edge drag) the height — and therefore the
|
||||
// computed floor — changes on every animation frame, so the unguarded `setZoom(floor)`
|
||||
// re-snapped the camera ~60x/sec and re-rendered the whole globe: visible strobing, a
|
||||
// seizure risk for photosensitive users. Two guards, both required:
|
||||
// 1. IDEMPOTENCE — only touch minZoom when the floor actually CHANGED. A pure width
|
||||
// drag, or any resize that leaves the floor alone, now costs nothing.
|
||||
// 2. DEBOUNCE the camera snap — clamping the zoom is a visible camera move, so it must
|
||||
// happen ONCE when the size settles, never mid-drag. The floor itself is applied
|
||||
// immediately (cheap, non-visual); only the snap waits.
|
||||
let _lastFloor = initialMinZoom
|
||||
let _snapT = null
|
||||
_onResize = () => {
|
||||
const m = map.value
|
||||
if (!m) return
|
||||
const floor = floorFor(el.clientWidth, el.clientHeight)
|
||||
m.setMinZoom(floor)
|
||||
if (m.getZoom() < floor) m.setZoom(floor)
|
||||
if (floor !== _lastFloor) {
|
||||
_lastFloor = floor
|
||||
m.setMinZoom(floor)
|
||||
}
|
||||
// Defer the visible clamp until the resize stream goes quiet.
|
||||
if (m.getZoom() < floor) {
|
||||
if (_snapT) clearTimeout(_snapT)
|
||||
_snapT = setTimeout(() => {
|
||||
_snapT = null
|
||||
const mm = map.value
|
||||
if (!mm) return
|
||||
const f = floorFor(el.clientWidth, el.clientHeight)
|
||||
// jumpTo, not setZoom: no easing/animation, so the correction lands in a single
|
||||
// frame instead of tweening (another motion source for reduced-motion users).
|
||||
if (mm.getZoom() < f) { try { mm.jumpTo({ zoom: f }) } catch (_) { mm.setZoom(f) } }
|
||||
}, 150)
|
||||
} else if (_snapT) {
|
||||
// the drag brought us back inside the floor — cancel the pending correction
|
||||
clearTimeout(_snapT); _snapT = null
|
||||
}
|
||||
}
|
||||
_clearSnap = () => { if (_snapT) { clearTimeout(_snapT); _snapT = null } }
|
||||
// Mapbox fires 'resize' after map.resize(); also listen to window resize +
|
||||
// orientationchange so panel toggles / breakpoints / device rotation recompute.
|
||||
map.value.on('resize', _onResize)
|
||||
|
|
@ -480,6 +521,8 @@ export function useMapInstance({
|
|||
}
|
||||
_onResize = null
|
||||
}
|
||||
// Drop any pending debounced zoom-floor snap so it can never fire on a dead map.
|
||||
if (_clearSnap) { try { _clearSnap() } catch (_) {} _clearSnap = null }
|
||||
// Tear down the gray side-edge touch handlers (mirrors registration above).
|
||||
if (_detachEdgeBorders) { _detachEdgeBorders(); _detachEdgeBorders = null }
|
||||
map.value.remove()
|
||||
|
|
|
|||
250
library/layouts/StackLayout.vue
Normal file
250
library/layouts/StackLayout.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<!--
|
||||
StackLayout.vue — a brand-neutral, shared SCROLL-STACK layout for grouping elements top-to-bottom.
|
||||
|
||||
★ WHY THIS EXISTS (the second shared layout, alongside VerticalTabsLayout). VerticalTabsLayout shows
|
||||
ONE element at a time (tabs, fixed-viewport panes). StackLayout does the OPPOSITE: it renders ALL of
|
||||
its elements STACKED VERTICALLY, each at its NATURAL height, so the whole thing grows the page and the
|
||||
HOST page scrolls through them — exactly how a normal document / a doxa-life / nuxtinator page flows.
|
||||
Use it to GROUP two (or more) elements on top of each other and embed the group on a page you scroll.
|
||||
|
||||
★ SAME NESTED-ELEMENT CONTRACT as VerticalTabsLayout (so a shell composes other element-plugins). Pass
|
||||
:sections="[{ key, title?, sub?, element, src?, attrs? }]". For each section:
|
||||
element : a custom-element tag to render, e.g. "research-dashboard-map"
|
||||
src? : a bundle URL to inject (once, deduped) if `element` isn't registered yet
|
||||
attrs? : a map of attributes/props set on the element BEFORE it mounts ("props in advance")
|
||||
title? : an optional section heading rendered above the element (omit for a bare stack)
|
||||
A section can INSTEAD carry no `element` and use the #section-<key> slot (or the default slot) for
|
||||
host-rendered content — `element` is an ALTERNATIVE, not a replacement.
|
||||
|
||||
★ EAGER, NOT LAZY (the deliberate difference from tabs). Because the whole point is to scroll through
|
||||
everything, every section's element mounts on FIRST RENDER (not on activation). If you only want the
|
||||
visible ones to load, that's the tabs layout's job; a scroll-stack shows all.
|
||||
|
||||
★ THE LAYOUT OWNS THE SCROLL — the elements do NOT. This is the Driver's scroll-page contract: a
|
||||
plugin dropped in a section only needs ROOM to display at full height; it never needs its own
|
||||
scrollbar. The root is `display:flow-root` with NO fixed height and NO `position:absolute; inset:0`
|
||||
— so it takes its content's natural height and the PARENT PAGE scrolls through everything. Each
|
||||
nested element is given `display:block; width:100%` and whatever height IT wants (a 900px dashboard
|
||||
makes the page 900px taller). Because many of our plugins ship their own `position:absolute; inset:0`
|
||||
/ `height:100%` (they were authored to fill a fixed-viewport tab pane), the host pane is defensive:
|
||||
it is `position:relative` and DEMOTES an absolutely/fixed-positioned direct child back to
|
||||
`position:static` so it lays out in the flow instead of collapsing the section to 0px. For such a
|
||||
self-sizing element that still can't measure its own height in flow, pass :min-section-height (or a
|
||||
section's own `minHeight`) to reserve room. Optionally cap a section with :max-section-height to give
|
||||
a genuinely tall element an inner scroll instead of an endless page (default: no cap = pure page
|
||||
scroll — the doxa-life / nuxtinator page-flow model).
|
||||
|
||||
★ EMBEDDING A LAYOUT INSIDE A LAYOUT (scroll-page in the cap-5 main). The cap-5 SPA's shell is
|
||||
VerticalTabsLayout (sidebar + a FIXED-VIEWPORT content frame whose pane is `position:absolute;
|
||||
inset:0`). A raw StackLayout dropped straight into that pane would be clipped by the inset overlay,
|
||||
so the page can't grow. The proven layout-inside-a-layout path is therefore the IFRAME LEAF: a tab
|
||||
is registered `mode:'iframe'` and its leaf page renders THIS layout at document root, where the
|
||||
document itself owns the scroll. dashboard-plus-grid does exactly this (a cap-5 tab whose leaf is a
|
||||
StackLayout) — scroll-page inside the vertical-tabs main, proven end to end.
|
||||
|
||||
★ SHADOW-DOM SAFE. Like VerticalTabsLayout, this is typically imported as a CHILD of a customElement
|
||||
root, so its <style> would be dropped from the shadow root. It injects its critical CSS via
|
||||
useShadowStyles() at setup(). Keep STL_SHADOW_CSS and the <style> mirror in sync.
|
||||
|
||||
Slots: #brand / #meta (an optional top bar — omitted entirely if neither is used) · #section-<key>
|
||||
(host content for a section without `element`) · #default (a trailing host area at the bottom).
|
||||
-->
|
||||
<template>
|
||||
<div class="stl">
|
||||
<header class="stl-top" v-if="$slots.brand || $slots.meta || title">
|
||||
<span class="stl-brand"><slot name="brand" /></span>
|
||||
<b class="stl-title" v-if="title">{{ title }}</b>
|
||||
<span class="stl-meta"><slot name="meta" /></span>
|
||||
</header>
|
||||
|
||||
<div class="stl-stack">
|
||||
<section
|
||||
v-for="sec in sections" :key="sec.key" class="stl-sec"
|
||||
:class="{ 'stl-sec-fill': hasDefiniteHeight(sec) }"
|
||||
:style="sectionStyle(sec)">
|
||||
<div class="stl-sec-head" v-if="sec.title">
|
||||
<b class="stl-sec-title">{{ sec.title }}</b>
|
||||
<span class="stl-sec-sub" v-if="sec.sub">{{ sec.sub }}</span>
|
||||
</div>
|
||||
<!-- element-bearing section → a host pane the element is mounted into imperatively. -->
|
||||
<div v-if="sec.element" class="stl-host" :ref="el => registerHost(sec.key, el)"></div>
|
||||
<!-- otherwise → host-rendered content via a per-section slot. -->
|
||||
<div v-else class="stl-slot"><slot :name="'section-' + sec.key" /></div>
|
||||
</section>
|
||||
<div class="stl-tail"><slot /></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
||||
// Inject a bundle script exactly once per URL (deduped across all layout instances). Same helper the
|
||||
// tabs layout + the SPA use — resolves after the IIFE's customElements.define has run.
|
||||
const _stlLoaded = {}
|
||||
function injectScriptOnce(url) {
|
||||
if (!url) return Promise.resolve()
|
||||
if (_stlLoaded[url]) return _stlLoaded[url]
|
||||
_stlLoaded[url] = new Promise((ok, fail) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = url
|
||||
s.onload = () => ok()
|
||||
s.onerror = () => { delete _stlLoaded[url]; fail(new Error('failed to load ' + url)) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return _stlLoaded[url]
|
||||
}
|
||||
|
||||
function waitForDefine(tag, timeoutMs = 5000) {
|
||||
if (typeof customElements === 'undefined') return Promise.resolve()
|
||||
if (customElements.get(tag)) return Promise.resolve()
|
||||
if (customElements.whenDefined) {
|
||||
return Promise.race([
|
||||
customElements.whenDefined(tag),
|
||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout waiting for <' + tag + '>')), timeoutMs))
|
||||
])
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Mirror of the <style> block for the shadow root (kept comment/interp-free for the strip plugin).
|
||||
const STL_SHADOW_CSS = `
|
||||
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
|
||||
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
|
||||
.stl-brand { display:inline-flex; align-items:center; }
|
||||
.stl-brand:empty { display:none; }
|
||||
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
|
||||
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
|
||||
.stl-stack { display:flex; flex-direction:column; }
|
||||
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
|
||||
.stl-sec:last-child { border-bottom:0; }
|
||||
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
|
||||
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
|
||||
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
|
||||
.stl-host { display:block; width:100%; min-width:0; position:relative; }
|
||||
.stl-host > * { display:block; width:100%; }
|
||||
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
|
||||
.stl-slot { display:block; width:100%; }
|
||||
.stl-tail:empty { display:none; }
|
||||
.stl-sec-fill { position:relative; overflow:hidden; }
|
||||
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
|
||||
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
|
||||
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
|
||||
.stl ::-webkit-scrollbar { width:8px; height:8px; }
|
||||
.stl ::-webkit-scrollbar-track { background:transparent; }
|
||||
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
|
||||
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
|
||||
.stl ::-webkit-scrollbar-corner { background:transparent; }
|
||||
`
|
||||
|
||||
export default defineComponent({
|
||||
name: 'StackLayout',
|
||||
props: {
|
||||
// [{ key, title?, sub?, element?, src?, attrs? }] — rendered top-to-bottom, all eager.
|
||||
sections: { type: Array, default: () => [] },
|
||||
// Optional top-bar title (omit for a bare stack with no header).
|
||||
title: { type: String, default: '' },
|
||||
// Optional per-section max height (e.g. '70vh') → that section gets an inner scroll instead of an
|
||||
// unbounded page. Default '' = no cap, pure page scroll (the doxa-life/nuxtinator page-flow model).
|
||||
maxSectionHeight: { type: String, default: '' },
|
||||
// Optional default MIN height for every section (e.g. '60vh') → reserves room for a self-sizing
|
||||
// element that ships its own fixed layout and can't measure its natural height in flow. A section
|
||||
// can override per-item with its own `minHeight`. Default '' = no floor (pure natural height).
|
||||
minSectionHeight: { type: String, default: '' }
|
||||
},
|
||||
setup() {
|
||||
useShadowStyles(STL_SHADOW_CSS, 'stack-layout')
|
||||
},
|
||||
data() {
|
||||
return { _hosts: {}, _mounted: {} }
|
||||
},
|
||||
mounted() {
|
||||
// Eager: mount every element-bearing section on first render (scroll-through shows all).
|
||||
for (const sec of (this.sections || [])) {
|
||||
if (sec.element) this.mountSection(sec)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
registerHost(key, el) { if (el) this._hosts[key] = el },
|
||||
// Per-section wrapper style: an optional inner-scroll cap (maxHeight) and/or a reserved floor
|
||||
// (minHeight, per-section override → layout default). Returns null when neither applies so a plain
|
||||
// section stays pure natural-height page-flow.
|
||||
sectionStyle(sec) {
|
||||
const s = {}
|
||||
const max = sec.maxHeight || this.maxSectionHeight
|
||||
const min = sec.minHeight || this.minSectionHeight
|
||||
// A definite-height section (min===max) is a fixed BAND: it must NOT get an inner section scroll —
|
||||
// the nested element (via .stl-sec-fill) fills it and owns any inner scroll, and the PAGE scrolls
|
||||
// past it. Only a cap-WITHOUT-a-matching-floor gets the maxHeight inner-scroll (`overflow:auto`).
|
||||
const definite = !!(max && min && max === min)
|
||||
if (max) { s.maxHeight = max; if (!definite) s.overflow = 'auto' }
|
||||
if (min) s.minHeight = min
|
||||
return Object.keys(s).length ? s : null
|
||||
},
|
||||
// A section is "definite-height" when it pins BOTH a floor and a cap to the SAME value (min===max):
|
||||
// its height is fixed, not natural. Such a section adds `.stl-sec-fill` so its host/slot + the nested
|
||||
// element stretch to fill it — the escape hatch for a fixed-viewport plugin (e.g. the research
|
||||
// dashboard) that ships `height:100%` and needs a definite parent height to resolve against, while
|
||||
// the OUTER page still grows below the section so page-scroll reaches the next section.
|
||||
hasDefiniteHeight(sec) {
|
||||
const max = sec.maxHeight || this.maxSectionHeight
|
||||
const min = sec.minHeight || this.minSectionHeight
|
||||
return !!(max && min && max === min)
|
||||
},
|
||||
mountSection(sec) {
|
||||
if (this._mounted[sec.key]) return
|
||||
this._mounted[sec.key] = true
|
||||
const host = this._hosts[sec.key]
|
||||
if (!host) return
|
||||
injectScriptOnce(sec.src)
|
||||
.then(() => waitForDefine(sec.element))
|
||||
.then(() => {
|
||||
if (!customElements.get(sec.element)) return
|
||||
const el = document.createElement(sec.element)
|
||||
const attrs = sec.attrs || {}
|
||||
for (const k in attrs) {
|
||||
const val = attrs[k]
|
||||
// props-in-advance: set as a property AND an attribute (custom elements read either).
|
||||
try { el[k] = val } catch { /* ignore */ }
|
||||
try { el.setAttribute(k, val == null ? '' : String(val)) } catch { /* ignore invalid attr name */ }
|
||||
}
|
||||
host.innerHTML = ''
|
||||
host.appendChild(el)
|
||||
})
|
||||
.catch(() => { /* a failed section leaves its host empty; the rest of the stack still renders */ })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Mirror of STL_SHADOW_CSS for dev / light-DOM readability. Keep the two in sync.
|
||||
Brand-neutral default dark theme; consumers override via the --stl-* custom properties. */
|
||||
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
|
||||
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
|
||||
.stl-brand { display:inline-flex; align-items:center; }
|
||||
.stl-brand:empty { display:none; }
|
||||
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
|
||||
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
|
||||
.stl-stack { display:flex; flex-direction:column; }
|
||||
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
|
||||
.stl-sec:last-child { border-bottom:0; }
|
||||
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
|
||||
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
|
||||
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
|
||||
.stl-host { display:block; width:100%; min-width:0; position:relative; }
|
||||
.stl-host > * { display:block; width:100%; }
|
||||
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
|
||||
.stl-slot { display:block; width:100%; }
|
||||
.stl-tail:empty { display:none; }
|
||||
.stl-sec-fill { position:relative; overflow:hidden; }
|
||||
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
|
||||
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
|
||||
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
|
||||
.stl ::-webkit-scrollbar { width:8px; height:8px; }
|
||||
.stl ::-webkit-scrollbar-track { background:transparent; }
|
||||
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
|
||||
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
|
||||
.stl ::-webkit-scrollbar-corner { background:transparent; }
|
||||
</style>
|
||||
|
|
@ -49,19 +49,46 @@
|
|||
`src`, the bundle is injected lazily (once per src) at first activation, and the element is
|
||||
created only after customElements.get(tag) resolves (injectScriptOnce + waitForDefine).
|
||||
|
||||
★ DEFAULT DARK THEME. The inlined token values are just colours: a neutral-900 canvas, a SOLID
|
||||
accented active row (not a coloured gradient), subtle borders, and green-400 (#00DC82) as the one
|
||||
accent. They ship as literals (a shadow-DOM custom element cannot import an app's design tokens —
|
||||
that is the boundary), so the rail looks finished out of the box with zero runtime dependency. A
|
||||
consumer can override the look via the CSS custom properties (--vtl-accent, --vtl-border, etc.).
|
||||
★ DEFAULT MATERIAL LIGHT THEME. The inlined token values are just colours, sourced from the live
|
||||
DX-life material palette (read-only): a light canvas (#f3f3f1 surface-default), white elevated
|
||||
chrome (#fff), brand-dark text (#3b463d), secondary-light borders (#d1d4cd), muted (#6d766e), and
|
||||
the sage brand-primary (#92b195) as the one accent. They ship as literals (a shadow-DOM custom
|
||||
element cannot import an app's design tokens — that is the boundary), so the rail looks finished
|
||||
out of the box with zero runtime dependency. A consumer can override the look via the CSS custom
|
||||
properties (--vtl-accent, --vtl-border, --vtl-elevated, --vtl-accented, --vtl-muted).
|
||||
|
||||
The rail is keyboard-accessible (role="tablist", ArrowUp/Down + Home/End move focus & select), the
|
||||
active row is highlighted, and the whole thing is responsive (rail collapses to an off-canvas drawer
|
||||
under 760px, toggled by the ☰ button).
|
||||
active row is highlighted, and the whole thing is responsive.
|
||||
|
||||
★ OPTIONAL HEADER (`header` prop, default true). The top bar (#brand / title / #meta) is only
|
||||
rendered when `header` is true. Pass :header="false" for a sidebar-only, full-page element (e.g.
|
||||
embedded in a site that already has its own header — stacked headers look bad). #toolbar is
|
||||
unaffected (it lives in the content column, not the header). plugin-studio keeps its header; the
|
||||
DX-Maps SPA turns it off.
|
||||
|
||||
★ COLLAPSIBLE RAIL (desktop + mobile). A clearly-visible toggle at the top of the rail collapses it:
|
||||
- DESKTOP (≥760px): the rail shrinks to a SLIM ICON RAIL (icons stay clickable, labels/sections
|
||||
hide) and expands back. State is `railCollapsed`. The rail width animates via a single CSS custom
|
||||
property (--vtl-rail: 256px↔60px) with a `width` transition, and BOTH the rail and the content pane
|
||||
set `contain:layout paint` so the collapse never reflows a heavy mounted map/element in the content
|
||||
frame — a smooth, GPU-friendly 60fps transition rather than the per-frame layout thrash a
|
||||
flex-basis animation caused. Because a header-less element is nav + content only, a slim rail keeps
|
||||
every tab reachable rather than leaving a blank page.
|
||||
- MOBILE (<760px): the rail is an off-canvas DRAWER toggled open/closed via `navOpen` (the ☰ lives
|
||||
in the rail toggle too).
|
||||
|
||||
▸ STATE RECONCILIATION (the fix for the "extremely buggy" collapse). `navOpen` (mobile drawer) and
|
||||
`railCollapsed` (desktop slim rail) are two boolean state machines with different meanings on each
|
||||
side of the 760px breakpoint. A reactive `narrow` flag mirrors the media query via a resize
|
||||
listener; on every breakpoint crossing `reconcileForWidth()` snaps the flags into a coherent state
|
||||
(desktop → navOpen=true so the rail is always visible; mobile → navOpen=false + railCollapsed=false
|
||||
so the drawer starts closed and reopens full-width). The rail's visibility binds to the media-aware
|
||||
`railShown` computed (NOT raw navOpen), so a mobile-closed drawer can no longer hide the whole rail
|
||||
after a resize back to desktop — the previous broken-restore bug.
|
||||
-->
|
||||
<template>
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen }">
|
||||
<header class="vtl-top">
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }">
|
||||
<header class="vtl-top" v-if="header">
|
||||
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
|
||||
aria-label="Toggle list">☰</button>
|
||||
<span class="vtl-brand"><slot name="brand" /></span>
|
||||
|
|
@ -70,7 +97,24 @@
|
|||
</header>
|
||||
|
||||
<div class="vtl-body">
|
||||
<nav class="vtl-nav" :aria-label="navLabel" v-show="navOpen" role="tablist" aria-orientation="vertical">
|
||||
<!-- Rail visibility is CSS-driven via `transform`, never v-show/display:none — a display:none element
|
||||
cannot animate, so the drawer must stay in the DOM and only slide. DESKTOP: the rail is always
|
||||
on-screen (translateX(0), or -196px as a slim icon rail when railCollapsed). MOBILE: the rail is
|
||||
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
|
||||
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
|
||||
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
|
||||
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)" role="tablist" aria-orientation="vertical">
|
||||
<!-- Rail toggle: always visible, collapses the rail (slim icon rail on desktop; off-canvas
|
||||
drawer on mobile). The chevron points the way the click will move the rail. -->
|
||||
<button
|
||||
type="button" class="vtl-collapse"
|
||||
:aria-expanded="String(!railCollapsed)"
|
||||
:aria-label="railCollapsed ? 'Expand list' : 'Collapse list'"
|
||||
@click="toggleRail">
|
||||
<span class="vtl-collapse-ic" aria-hidden="true" v-html="iconSvg('chevron')"></span>
|
||||
<span class="vtl-collapse-tx">Collapse</span>
|
||||
</button>
|
||||
|
||||
<!-- GROUPED shape: labelled sections (always-open) or collapsible folders. -->
|
||||
<template v-if="groups && groups.length">
|
||||
<div class="vtl-grp" v-for="grp in groups" :key="grp.key">
|
||||
|
|
@ -79,13 +123,14 @@
|
|||
v-if="grp.folder" type="button" class="vtl-tier vtl-tier-btn"
|
||||
:aria-expanded="String(!isFolderClosed(grp))"
|
||||
@click="toggleFolder(grp.key)">
|
||||
<span class="vtl-tier-ic" v-if="grp.icon" aria-hidden="true" v-html="iconSvg(grp.icon)"></span>
|
||||
<!-- NO section-header logo (Driver rule: logos only on the vertical TAB rows, never on the
|
||||
section dividers). The folder chevron below is an affordance, not a logo, so it stays. -->
|
||||
<span class="vtl-tl">{{ grp.label }}</span>
|
||||
<span class="vtl-chev" :class="{ open: !isFolderClosed(grp) }" aria-hidden="true" v-html="iconSvg('chevron')"></span>
|
||||
</button>
|
||||
<!-- SECTION heading = a static always-visible label (with optional badge/count). -->
|
||||
<!-- SECTION heading = a static always-visible label (with optional badge/count). No logo here
|
||||
either — section dividers are text-only (Driver rule); row icons live on the tabs below. -->
|
||||
<div v-else class="vtl-tier">
|
||||
<span class="vtl-tier-ic" v-if="grp.icon" aria-hidden="true" v-html="iconSvg(grp.icon)"></span>
|
||||
<span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span>
|
||||
<span class="vtl-tl">{{ grp.label }}</span>
|
||||
<span class="vtl-td" v-if="grp.count != null">{{ grp.count }}</span>
|
||||
|
|
@ -101,6 +146,10 @@
|
|||
@click="pick(row.key)" @keydown="onKey($event)">
|
||||
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
|
||||
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
|
||||
<!-- Slim-rail fallback (desktop collapsed only): a row with NEITHER icon NOR emoji would
|
||||
otherwise be an empty hit-target in the icon strip. Show the label's initial so a
|
||||
general-library consumer that omits icons still gets a usable slim rail. -->
|
||||
<span class="vtl-tab-fallback" aria-hidden="true" v-else>{{ initial(row.label) }}</span>
|
||||
<span class="vtl-tab-text">
|
||||
<span class="vtl-tab-label">{{ row.label }}<span class="vtl-tab-badge" v-if="row.badge">{{ row.badge }}</span></span>
|
||||
<span class="vtl-tab-sub" v-if="row.sub"><span v-html="row.sub"></span></span>
|
||||
|
|
@ -121,6 +170,7 @@
|
|||
@click="pick(row.key)" @keydown="onKey($event)">
|
||||
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
|
||||
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
|
||||
<span class="vtl-tab-fallback" aria-hidden="true" v-else>{{ initial(row.label) }}</span>
|
||||
<span class="vtl-tab-text">
|
||||
<span class="vtl-tab-label">{{ row.label }}</span>
|
||||
<span class="vtl-tab-sub" v-if="row.sub">{{ row.sub }}</span>
|
||||
|
|
@ -130,7 +180,11 @@
|
|||
</nav>
|
||||
|
||||
<main class="vtl-main">
|
||||
<slot name="toolbar" />
|
||||
<!-- The #toolbar strip (locale switcher etc.) is part of the header chrome, so it renders ONLY
|
||||
when header is on. Driver rule: a header-off, sidebar-only element (doxa-maps-spa) inherits
|
||||
its language from the PARENT via the `language` prop and shows NO standalone locale dropdown;
|
||||
a header-on host (plugin-studio) DOES get the toolbar/locale dropdown. -->
|
||||
<slot name="toolbar" v-if="header" />
|
||||
<div class="vtl-content">
|
||||
<!-- Lazily-hosted nested elements: one pane per element-bearing row, rendered only after its
|
||||
tab has been activated once (v-if on activatedKeys), then kept alive (v-show) so its
|
||||
|
|
@ -191,18 +245,47 @@ function waitForDefine(tag, timeoutMs = 5000) {
|
|||
// Injected into the shadow root at mount (see the header comment for the full why). Kept free of
|
||||
// block comments and ${} substitutions so the build-time strip-shadow-css plugin ships it verbatim.
|
||||
const VTL_SHADOW_CSS = `
|
||||
.vtl { position:absolute; inset:0; display:flex; flex-direction:column; height:100%; width:100%; min-height:420px; background:#171717; color:#f5f5f5; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#00DC82; --vtl-border:#262626; --vtl-elevated:#1f1f1f; --vtl-accented:#404040; --vtl-muted:#a3a3a3; }
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#1f1f1f; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#f5f5f5; background:#262626; border:1px solid #404040; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
.vtl-menu:hover { background:#404040; }
|
||||
/* STRUCTURAL DISPLAY — the !important is deliberate, not a hack. This layout's display mode is
|
||||
load-bearing: it CANNOT function as a block. As a library/ (high-bar) component it ships across
|
||||
all 5 deployment methods, and a host (app-shell direct-mount, WordPress, mobile webview) may force
|
||||
its own inline display onto the element. Declaring it non-overridable is the correct contract.
|
||||
grid-template-rows: auto 1fr = header takes its natural height, .vtl-body gets a DEFINITE
|
||||
remaining track — no dependence on in-flow children (nav/main are absolute for the rail slide). */
|
||||
.vtl { position:absolute; inset:0; display:grid !important; grid-template-rows:auto 1fr; height:100%; width:100%; min-height:420px; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#92b195; --vtl-border:#d1d4cd; --vtl-elevated:#ffffff; --vtl-accented:#d1d4cd; --vtl-muted:#6d766e; --vtl-rail:256px; }
|
||||
/* HEADER-OFF (header=false, e.g. the cap-5 doxa-maps-spa): the .vtl-top element is NOT rendered, so
|
||||
.vtl-body becomes the ONLY grid child and auto-places into row 1 — the auto row — which has no
|
||||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#f5f5f5; }
|
||||
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
|
||||
.vtl-meta { color:var(--vtl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
|
||||
.vtl-body { flex:1 1 auto; display:flex; min-height:0; }
|
||||
.vtl-nav { flex:0 0 256px; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#1f1f1f; scrollbar-width:thin; scrollbar-color:#404040 transparent; }
|
||||
.vtl-nav::-webkit-scrollbar { width:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#404040; border-radius:8px; }
|
||||
/* GRID ROWS, not flex (BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN). The collapsible-rail design makes
|
||||
.vtl-nav and .vtl-main position:absolute (so the rail slides by transform without reflowing the
|
||||
heavy map sibling). That leaves .vtl-body with NO in-flow children → ZERO intrinsic height, so it
|
||||
could only fill via flex:1 from a flex parent. A host that forces display:block on .vtl (the
|
||||
app-shell direct-mounts cap-5 elements that way) made that flex inert → .vtl-body collapsed to 0
|
||||
→ black screen. The auto/1fr grid on .vtl gives this row a DEFINITE height by construction, so
|
||||
it no longer depends on the parent's display mode NOR on in-flow children. Header-off
|
||||
(header=false) still works: the auto row collapses to 0 and 1fr takes the full height. */
|
||||
.vtl-body { min-height:0; position:relative; display:flex; }
|
||||
.vtl-nav { position:absolute; top:0; bottom:0; left:0; z-index:5; width:256px; box-sizing:border-box; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#ffffff; scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; contain:layout paint; transform:translateX(0); transition:transform .2s cubic-bezier(.4,0,.2,1); will-change:transform; }
|
||||
/* Thin OVERLAY scrollbar: transparent track so the bar costs no visual space and sits flush to
|
||||
the nav edge; low-contrast thumb that darkens on hover. Matches the research-dashboard rails. */
|
||||
.vtl-nav::-webkit-scrollbar { width:8px; height:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-track { background:transparent; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb:hover { background:#9aa197; }
|
||||
.vtl-collapse { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
|
||||
.vtl-collapse:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-collapse:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-collapse-ic { flex:0 0 auto; display:inline-flex; transition:transform .16s ease; transform:rotate(180deg); }
|
||||
.vtl-collapse-ic svg { width:16px; height:16px; }
|
||||
.vtl-collapse-tx { white-space:nowrap; }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -219,31 +302,63 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl-chev.open { transform:rotate(90deg); }
|
||||
.vtl-rows { display:flex; flex-direction:column; gap:1px; }
|
||||
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 10px; cursor:pointer; border-radius:8px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
||||
.vtl-tab:hover { background:var(--vtl-accented); color:#f5f5f5; }
|
||||
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#f5f5f5; font-weight:500; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
||||
.vtl-tab.active .vtl-tab-ic { color:var(--vtl-accent); }
|
||||
.vtl-tab-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
|
||||
.vtl-tab-ic svg { width:20px; height:20px; }
|
||||
.vtl-tab-emoji { font-size:17px; line-height:1; flex:0 0 auto; }
|
||||
.vtl-tab-fallback { display:none; flex:0 0 auto; width:20px; height:20px; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:var(--vtl-muted); background:var(--vtl-accented); border-radius:6px; }
|
||||
.vtl-tab.active .vtl-tab-fallback { color:var(--vtl-accent); }
|
||||
.vtl-tab-text { display:flex; flex-direction:column; min-width:0; }
|
||||
.vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; }
|
||||
.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; }
|
||||
.vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
|
||||
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
|
||||
.vtl-main { flex:1 1 auto; position:relative; min-width:0; display:flex; flex-direction:column; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
|
||||
.vtl-main { position:absolute; top:0; bottom:0; right:0; left:256px; min-width:0; display:flex; flex-direction:column; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout paint; }
|
||||
.vtl-host { position:absolute; inset:0; }
|
||||
.vtl-host-slot { position:absolute; inset:0; }
|
||||
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#94a3b8; font-size:13px; padding:20px; }
|
||||
.vtl-host-hint.err { color:#f87171; }
|
||||
.vtl-host-hint small { color:#64748b; }
|
||||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-196px); }
|
||||
.vtl.rail-collapsed .vtl-main { left:60px; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:196px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:200px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
|
||||
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
|
||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
|
||||
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl-menu { display:inline-block; }
|
||||
.vtl-meta { display:none; }
|
||||
.vtl-nav { position:absolute; z-index:20; top:0; bottom:0; left:0; box-shadow:0 0 28px rgba(0,0,0,.55); }
|
||||
.vtl:not(.nav-open) .vtl-nav { display:none; }
|
||||
/* MOBILE = the SAME composited-transform model, taken to its full-off-canvas extreme: a full-width
|
||||
drawer that slides in/out via transform (never a layout property), so opening/closing the drawer
|
||||
never reflows the content. Content is full-width (left:0) and stays put; the drawer floats over it. */
|
||||
.vtl-nav { z-index:20; width:256px; box-shadow:0 0 28px rgba(0,0,0,.55); transform:translateX(-100%); }
|
||||
.vtl.nav-open .vtl-nav { transform:translateX(0); }
|
||||
.vtl-main { left:0; }
|
||||
/* The desktop slim-rail collapse has no meaning on a phone — reconcileForWidth() clears railCollapsed
|
||||
at this width, but neutralize its CSS too (belt-and-suspenders) so a full labelled drawer always
|
||||
shows: no partial slide, no icon-column padding, labels/sections visible. */
|
||||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-100%); }
|
||||
.vtl.rail-collapsed.nav-open .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed .vtl-main { left:0; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:10px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
||||
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -260,7 +375,12 @@ export default defineComponent({
|
|||
groups: { type: Array, default: () => [] },
|
||||
navLabel: { type: String, default: 'Sections' },
|
||||
// Start with the rail open (desktop). Auto-collapses on narrow screens after mount.
|
||||
initialNavOpen: { type: Boolean, default: true }
|
||||
initialNavOpen: { type: Boolean, default: true },
|
||||
// Render the top bar (#brand / title / #meta). Pass false for a sidebar-only, full-page element
|
||||
// (embedded where a host header already exists — stacked headers look bad). #toolbar is unaffected.
|
||||
header: { type: Boolean, default: true },
|
||||
// Start the desktop rail collapsed (slim icon rail). Off by default so the rail opens expanded.
|
||||
initialRailCollapsed: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['update:active'],
|
||||
setup() {
|
||||
|
|
@ -275,11 +395,19 @@ export default defineComponent({
|
|||
// tab (so their pane is rendered + element mounted); hostState/hostError track each pane's load.
|
||||
// A row not in activatedKeys is never rendered — the lazy contract (no visit → no cost).
|
||||
return {
|
||||
navOpen: this.initialNavOpen, _tabEls: {}, folderOverride: {},
|
||||
navOpen: this.initialNavOpen, railCollapsed: this.initialRailCollapsed, _tabEls: {}, folderOverride: {},
|
||||
// `narrow` mirrors the (max-width:760px) media query as REACTIVE state, kept current by a resize
|
||||
// listener. It lets us reconcile the two boolean state machines (navOpen = mobile drawer,
|
||||
// railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly.
|
||||
narrow: false, _mql: null, _onMedia: null,
|
||||
activatedKeys: {}, hostState: {}, hostError: {}, _hostSlots: {}, _hostMounted: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// Is the rail element visible at all? DESKTOP: always (it just shrinks when railCollapsed). MOBILE:
|
||||
// only when the off-canvas drawer is open (navOpen). Binding the rail's v-show to THIS (not raw
|
||||
// navOpen) prevents a mobile-closed drawer from hiding the rail after a resize back to desktop.
|
||||
railShown() { return this.narrow ? this.navOpen : true },
|
||||
// Every row/item flattened across whichever shape is in use (groups take precedence over items).
|
||||
allRows() {
|
||||
if (this.groups && this.groups.length) {
|
||||
|
|
@ -306,15 +434,57 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
mounted() {
|
||||
// Track the breakpoint reactively and RECONCILE state whenever we cross it — this is the fix for the
|
||||
// "extremely buggy" collapse: without it, `navOpen` (mobile drawer) and `railCollapsed` (desktop slim
|
||||
// rail) drifted out of the regime they were set in, and a mobile-closed drawer could hide the whole
|
||||
// desktop rail. On each change we snap the flags back into a coherent state for the new width.
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
|
||||
} catch { /* ignore */ }
|
||||
if (window.matchMedia) {
|
||||
this._mql = window.matchMedia('(max-width: 760px)')
|
||||
this.narrow = this._mql.matches
|
||||
this.reconcileForWidth()
|
||||
this._onMedia = (e) => { this.narrow = e.matches; this.reconcileForWidth() }
|
||||
// addEventListener is the modern API; addListener is the Safari<14 fallback.
|
||||
if (this._mql.addEventListener) this._mql.addEventListener('change', this._onMedia)
|
||||
else if (this._mql.addListener) this._mql.addListener(this._onMedia)
|
||||
}
|
||||
} catch { /* ignore — non-DOM/SSR guards */ }
|
||||
// The immediate `active` watcher may have run before the pane div existed; re-run for the current
|
||||
// active key now that the DOM is up, so an element-tab active at first render mounts.
|
||||
this.$nextTick(() => this.activateElement(this.active))
|
||||
},
|
||||
beforeUnmount() {
|
||||
try {
|
||||
if (this._mql && this._onMedia) {
|
||||
if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMedia)
|
||||
else if (this._mql.removeListener) this._mql.removeListener(this._onMedia)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
this._mql = null; this._onMedia = null
|
||||
},
|
||||
methods: {
|
||||
registerTab(key, el) { if (el) this._tabEls[key] = el },
|
||||
// The rail toggle. On a narrow screen the rail is an off-canvas drawer, so the toggle closes the
|
||||
// drawer (navOpen). On desktop it collapses/expands the slim icon rail (railCollapsed, persisted
|
||||
// in component data). Reading matchMedia at click time keeps one toggle correct in both modes.
|
||||
isNarrow() { return this.narrow },
|
||||
// Snap the two boolean state machines into a coherent state for the CURRENT width. Called on mount and
|
||||
// on every breakpoint crossing. DESKTOP: the rail is always visible, so force navOpen=true (a stale
|
||||
// mobile-closed drawer must never hide the desktop rail). MOBILE: the rail is an off-canvas drawer that
|
||||
// should start CLOSED, and the desktop slim-rail collapse has no meaning here, so clear railCollapsed so
|
||||
// reopening the drawer always shows a full-width, labelled list.
|
||||
reconcileForWidth() {
|
||||
if (this.narrow) {
|
||||
this.navOpen = false
|
||||
this.railCollapsed = false
|
||||
} else {
|
||||
this.navOpen = true
|
||||
}
|
||||
},
|
||||
toggleRail() {
|
||||
if (this.narrow) this.navOpen = !this.navOpen
|
||||
else this.railCollapsed = !this.railCollapsed
|
||||
},
|
||||
setHostSlot(key, el) { if (el) { this._hostSlots[key] = el; this.$nextTick(() => this.mountHostElement(key)) } },
|
||||
// Mark an element-row activated (renders its pane) on first activation; mount happens once the
|
||||
// pane's slot div exists (setHostSlot → mountHostElement). Idempotent: re-activating is a no-op.
|
||||
|
|
@ -366,6 +536,9 @@ export default defineComponent({
|
|||
// Resolve a LOCAL icon name to its inline SVG string (app-wrapper-material/icons.js). Names come
|
||||
// from OUR nav model (never user input); unknown names fall back to the library's own default.
|
||||
iconSvg(name) { return name ? localIcon(name) : '' },
|
||||
// First visible character of a label, uppercased — the slim-rail fallback glyph for a row that has
|
||||
// neither `icon` nor `emoji`, so the collapsed icon strip never shows an empty hit-target.
|
||||
initial(label) { const s = (label || '').trim(); return s ? s.charAt(0).toUpperCase() : '•' },
|
||||
// A folder is closed unless the user toggled it open, or (no toggle yet) unless it was authored
|
||||
// with open:true. `grp.open === true` opens by default; anything else starts collapsed.
|
||||
isFolderClosed(grp) {
|
||||
|
|
@ -381,9 +554,9 @@ export default defineComponent({
|
|||
},
|
||||
pick(key) {
|
||||
this.$emit('update:active', key)
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
|
||||
} catch { /* ignore */ }
|
||||
// On mobile, picking a tab closes the off-canvas drawer. On desktop navOpen must stay true (it gates
|
||||
// the desktop rail's visibility via railShown), so we only touch it when narrow.
|
||||
if (this.narrow) this.navOpen = false
|
||||
},
|
||||
// ArrowUp/Down cycle the rail; Home/End jump to the ends. Moves selection AND focus so a
|
||||
// keyboard user drives the shell exactly like a native tablist.
|
||||
|
|
@ -410,19 +583,48 @@ export default defineComponent({
|
|||
/* Mirror of VTL_SHADOW_CSS for dev / light-DOM readability. Keep the two in sync.
|
||||
Default dark theme (values are just colours, overridable via the --vtl-* custom properties):
|
||||
neutral-900 canvas, a SOLID accented active row (not a gradient), subtle borders, green-400
|
||||
(#00DC82) accent, inline SVG icons. */
|
||||
.vtl { position:absolute; inset:0; display:flex; flex-direction:column; height:100%; width:100%; min-height:420px; background:#171717; color:#f5f5f5; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#00DC82; --vtl-border:#262626; --vtl-elevated:#1f1f1f; --vtl-accented:#404040; --vtl-muted:#a3a3a3; }
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#1f1f1f; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#f5f5f5; background:#262626; border:1px solid #404040; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
.vtl-menu:hover { background:#404040; }
|
||||
(#92b195) accent, inline SVG icons. */
|
||||
/* STRUCTURAL DISPLAY — the !important is deliberate, not a hack. This layout's display mode is
|
||||
load-bearing: it CANNOT function as a block. As a library/ (high-bar) component it ships across
|
||||
all 5 deployment methods, and a host (app-shell direct-mount, WordPress, mobile webview) may force
|
||||
its own inline display onto the element. Declaring it non-overridable is the correct contract.
|
||||
grid-template-rows: auto 1fr = header takes its natural height, .vtl-body gets a DEFINITE
|
||||
remaining track — no dependence on in-flow children (nav/main are absolute for the rail slide). */
|
||||
.vtl { position:absolute; inset:0; display:grid !important; grid-template-rows:auto 1fr; height:100%; width:100%; min-height:420px; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#92b195; --vtl-border:#d1d4cd; --vtl-elevated:#ffffff; --vtl-accented:#d1d4cd; --vtl-muted:#6d766e; --vtl-rail:256px; }
|
||||
/* HEADER-OFF (header=false, e.g. the cap-5 doxa-maps-spa): the .vtl-top element is NOT rendered, so
|
||||
.vtl-body becomes the ONLY grid child and auto-places into row 1 — the auto row — which has no
|
||||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#f5f5f5; }
|
||||
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
|
||||
.vtl-meta { color:var(--vtl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
|
||||
.vtl-body { flex:1 1 auto; display:flex; min-height:0; }
|
||||
.vtl-nav { flex:0 0 256px; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#1f1f1f; scrollbar-width:thin; scrollbar-color:#404040 transparent; }
|
||||
.vtl-nav::-webkit-scrollbar { width:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#404040; border-radius:8px; }
|
||||
/* GRID ROWS, not flex (BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN). The collapsible-rail design makes
|
||||
.vtl-nav and .vtl-main position:absolute (so the rail slides by transform without reflowing the
|
||||
heavy map sibling). That leaves .vtl-body with NO in-flow children → ZERO intrinsic height, so it
|
||||
could only fill via flex:1 from a flex parent. A host that forces display:block on .vtl (the
|
||||
app-shell direct-mounts cap-5 elements that way) made that flex inert → .vtl-body collapsed to 0
|
||||
→ black screen. The auto/1fr grid on .vtl gives this row a DEFINITE height by construction, so
|
||||
it no longer depends on the parent's display mode NOR on in-flow children. Header-off
|
||||
(header=false) still works: the auto row collapses to 0 and 1fr takes the full height. */
|
||||
.vtl-body { min-height:0; position:relative; display:flex; }
|
||||
.vtl-nav { position:absolute; top:0; bottom:0; left:0; z-index:5; width:256px; box-sizing:border-box; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#ffffff; scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; contain:layout paint; transform:translateX(0); transition:transform .2s cubic-bezier(.4,0,.2,1); will-change:transform; }
|
||||
/* Thin OVERLAY scrollbar: transparent track so the bar costs no visual space and sits flush to
|
||||
the nav edge; low-contrast thumb that darkens on hover. Matches the research-dashboard rails. */
|
||||
.vtl-nav::-webkit-scrollbar { width:8px; height:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-track { background:transparent; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb:hover { background:#9aa197; }
|
||||
.vtl-collapse { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
|
||||
.vtl-collapse:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-collapse:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-collapse-ic { flex:0 0 auto; display:inline-flex; transition:transform .16s ease; transform:rotate(180deg); }
|
||||
.vtl-collapse-ic svg { width:16px; height:16px; }
|
||||
.vtl-collapse-tx { white-space:nowrap; }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -439,30 +641,62 @@ export default defineComponent({
|
|||
.vtl-chev.open { transform:rotate(90deg); }
|
||||
.vtl-rows { display:flex; flex-direction:column; gap:1px; }
|
||||
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 10px; cursor:pointer; border-radius:8px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
||||
.vtl-tab:hover { background:var(--vtl-accented); color:#f5f5f5; }
|
||||
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#f5f5f5; font-weight:500; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
||||
.vtl-tab.active .vtl-tab-ic { color:var(--vtl-accent); }
|
||||
.vtl-tab-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
|
||||
.vtl-tab-ic svg { width:20px; height:20px; }
|
||||
.vtl-tab-emoji { font-size:17px; line-height:1; flex:0 0 auto; }
|
||||
.vtl-tab-fallback { display:none; flex:0 0 auto; width:20px; height:20px; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:var(--vtl-muted); background:var(--vtl-accented); border-radius:6px; }
|
||||
.vtl-tab.active .vtl-tab-fallback { color:var(--vtl-accent); }
|
||||
.vtl-tab-text { display:flex; flex-direction:column; min-width:0; }
|
||||
.vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; }
|
||||
.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; }
|
||||
.vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
|
||||
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
|
||||
.vtl-main { flex:1 1 auto; position:relative; min-width:0; display:flex; flex-direction:column; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
|
||||
.vtl-main { position:absolute; top:0; bottom:0; right:0; left:256px; min-width:0; display:flex; flex-direction:column; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout paint; }
|
||||
.vtl-host { position:absolute; inset:0; }
|
||||
.vtl-host-slot { position:absolute; inset:0; }
|
||||
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#94a3b8; font-size:13px; padding:20px; }
|
||||
.vtl-host-hint.err { color:#f87171; }
|
||||
.vtl-host-hint small { color:#64748b; }
|
||||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-196px); }
|
||||
.vtl.rail-collapsed .vtl-main { left:60px; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:196px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:200px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
|
||||
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
|
||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
|
||||
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl-menu { display:inline-block; }
|
||||
.vtl-meta { display:none; }
|
||||
.vtl-nav { position:absolute; z-index:20; top:0; bottom:0; left:0; box-shadow:0 0 28px rgba(0,0,0,.55); }
|
||||
.vtl:not(.nav-open) .vtl-nav { display:none; }
|
||||
/* MOBILE = the SAME composited-transform model, taken to its full-off-canvas extreme: a full-width
|
||||
drawer that slides in/out via transform (never a layout property), so opening/closing the drawer
|
||||
never reflows the content. Content is full-width (left:0) and stays put; the drawer floats over it. */
|
||||
.vtl-nav { z-index:20; width:256px; box-shadow:0 0 28px rgba(0,0,0,.55); transform:translateX(-100%); }
|
||||
.vtl.nav-open .vtl-nav { transform:translateX(0); }
|
||||
.vtl-main { left:0; }
|
||||
/* The desktop slim-rail collapse has no meaning on a phone — reconcileForWidth() clears railCollapsed
|
||||
at this width, but neutralize its CSS too (belt-and-suspenders) so a full labelled drawer always
|
||||
shows: no partial slide, no icon-column padding, labels/sections visible. */
|
||||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-100%); }
|
||||
.vtl.rail-collapsed.nav-open .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed .vtl-main { left:0; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:10px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
||||
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "Preconfigured instances of the composite <bp-big-picture>. provider:'maplibre' flags each generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so the element mounts INSTANTLY with no Mapbox token ladder — correct for this self-contained, mapless Cat-4 dashboard. generate-tree stamps the bundle's HEADLINE tag (bp-big-picture) on every instance page, so all preset instances render the COMPOSITE. The INNER elements (bp-research-header, bp-groups-table) are individually embeddable via their own registered tags + SRI script (see manifest.elements and the staging index.html section 2, which wires them standalone through the DOM).",
|
||||
"_comment": "Preconfigured instances of the composite <bp-big-picture>. provider:'maplibre' flags each generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so the element mounts INSTANTLY with no Mapbox token ladder — correct for this self-contained, mapless Cat-4 dashboard. generate-tree stamps the bundle's HEADLINE tag (bp-big-picture) on every instance page, so all preset instances render the COMPOSITE. The INNER elements (bp-research-header + the REAL cap-2 people-groups-grid) are individually embeddable via their own registered tags + SRI script (see manifest.elements and the staging index.html section 2, which wires them standalone through the DOM).",
|
||||
"instances": [
|
||||
{
|
||||
"id": "big-picture",
|
||||
|
|
|
|||
|
|
@ -108,15 +108,23 @@ function onGroupsFiltered(payload) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- User-facing footer: an unreached-people-groups research dashboard. The Cap-4
|
||||
architecture note (header↔grid via props/events, no shared bus, tab-1 of the SPA)
|
||||
lives in this file's header block, not in the UI where an end user reads it. -->
|
||||
<footer class="bp-foot">
|
||||
Cap-4 nested-element plugin · header↔grid via props/events (no shared bus) · tab-1 of the SPA
|
||||
Search and filter the world's people groups · least-reached shown first
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Self-size regardless of how a parent mounts us: a host that isn't a sized block makes
|
||||
.bp-root's height:100% resolve against nothing → 0-height collapse (the black-screen class).
|
||||
Declaring :host here means this element fills its slot even if the mounter forces no display
|
||||
(or the wrong one). See BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN-DISPLAY-BLOCK. */
|
||||
:host { display: block; width: 100%; height: 100%; }
|
||||
.bp-root {
|
||||
--bg: #0b0f14; --line: #1f2937; --ink: #e5e7eb; --mut: #94a3b8;
|
||||
--bg: #f3f3f1; --line: #d1d4cd; --ink: #3b463d; --mut: #6d766e;
|
||||
display: flex; flex-direction: column;
|
||||
width: 100%; height: 100%; min-height: 480px; overflow: hidden;
|
||||
background: var(--bg); color: var(--ink); box-sizing: border-box;
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
<script setup>
|
||||
/**
|
||||
* BpGroupsTable.vue — the Cat-2 people-groups PROP-TABLE (nested element of Big Picture).
|
||||
*
|
||||
* This is the "people-groups grid + its OWN search bar + dropdowns" from the research page,
|
||||
* lifted into a self-contained, individually-embeddable custom element (<bp-groups-table>).
|
||||
*
|
||||
* The Cat-2 contract (the whole point of this element):
|
||||
* - By DEFAULT it owns its internal search box + region/status dropdowns and filters
|
||||
* itself — drop it on any page and it just works standalone.
|
||||
* - Props can TURN OFF those internal controls so it is driven EXTERNALLY:
|
||||
* show-search="false" hides the internal search box
|
||||
* show-filters="false" hides the internal region/status dropdowns
|
||||
* …and then the parent (the Big Picture dashboard header, or any host page) feeds it
|
||||
* the `query` / `region` / `status` props instead. One element, two drive modes.
|
||||
* - It EMITS `groups-filtered` (detail = { query, region, status, count, slugs }) every
|
||||
* time the visible set changes, so an external driver can reflect the count/selection.
|
||||
* Emitted BOTH as a Vue emit AND as a native bubbling+composed DOM CustomEvent, so a
|
||||
* plain host page hears it when the element is embedded standalone.
|
||||
*
|
||||
* Precedence of a filter value = internal control (when shown) ELSE the incoming prop.
|
||||
* So turning a control off cedes that axis to the prop; leaving it on lets the user drive it.
|
||||
*/
|
||||
import { computed, ref, watch, getCurrentInstance } from 'vue'
|
||||
import {
|
||||
PEOPLE_GROUPS, REGIONS, STATUSES,
|
||||
regionName, statusName, statusOf, filterGroups,
|
||||
} from '../data/people-groups.js'
|
||||
|
||||
const props = defineProps({
|
||||
// externally-driven filter axes (used when the matching internal control is hidden).
|
||||
query: { type: String, default: '' },
|
||||
region: { type: String, default: '' },
|
||||
status: { type: String, default: '' },
|
||||
// toggles for the element's OWN controls. Accept string 'false' too (DOM attributes).
|
||||
showSearch: { type: [Boolean, String], default: true },
|
||||
showFilters: { type: [Boolean, String], default: true },
|
||||
})
|
||||
const emit = defineEmits(['groups-filtered'])
|
||||
|
||||
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)
|
||||
const useSearch = computed(() => truthy(props.showSearch))
|
||||
const useFilters = computed(() => truthy(props.showFilters))
|
||||
|
||||
// internal control state (only authoritative when the control is shown).
|
||||
const localQuery = ref('')
|
||||
const localRegion = ref('')
|
||||
const localStatus = ref('')
|
||||
|
||||
// effective filter = internal control (if shown) else the prop.
|
||||
const effQuery = computed(() => (useSearch.value ? localQuery.value : (props.query || '')))
|
||||
const effRegion = computed(() => (useFilters.value ? localRegion.value : (props.region || '')))
|
||||
const effStatus = computed(() => (useFilters.value ? localStatus.value : (props.status || '')))
|
||||
|
||||
const rows = computed(() =>
|
||||
filterGroups({ query: effQuery.value, region: effRegion.value, status: effStatus.value })
|
||||
.slice()
|
||||
.sort((a, b) => a.reached - b.reached) // least-reached first
|
||||
)
|
||||
|
||||
// ── emit `groups-filtered` whenever the visible set changes (Vue + DOM) ──────────
|
||||
const inst = getCurrentInstance()
|
||||
function hostEl() {
|
||||
const root = inst?.vnode?.el || inst?.proxy?.$el
|
||||
try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
|
||||
return root
|
||||
}
|
||||
watch(rows, (r) => {
|
||||
const detail = {
|
||||
query: effQuery.value, region: effRegion.value, status: effStatus.value,
|
||||
count: r.length, slugs: r.map((g) => g.slug),
|
||||
}
|
||||
emit('groups-filtered', detail)
|
||||
const el = hostEl()
|
||||
if (el && typeof el.dispatchEvent === 'function') {
|
||||
el.dispatchEvent(new CustomEvent('groups-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const fmtPop = (n) => {
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
const total = PEOPLE_GROUPS.length
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gt-root">
|
||||
<!-- The element's OWN controls: shown by default, hidden when driven externally. -->
|
||||
<div v-if="useSearch || useFilters" class="gt-controls">
|
||||
<div v-if="useSearch" class="gt-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input v-model="localQuery" type="search" placeholder="Search people groups…"
|
||||
aria-label="Search people groups" />
|
||||
</div>
|
||||
<template v-if="useFilters">
|
||||
<select v-model="localRegion" aria-label="Filter by region">
|
||||
<option value="">All regions</option>
|
||||
<option v-for="r in REGIONS" :key="r.id" :value="r.id">{{ r.name }}</option>
|
||||
</select>
|
||||
<select v-model="localStatus" aria-label="Filter by reach status">
|
||||
<option value="">All status</option>
|
||||
<option v-for="s in STATUSES" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="gt-summary">
|
||||
<strong>{{ rows.length }}</strong> / {{ total }} people groups
|
||||
<span v-if="effRegion"> · {{ regionName(effRegion) }}</span>
|
||||
<span v-if="effStatus"> · {{ statusName(effStatus) }}</span>
|
||||
<span v-if="effQuery"> · “{{ effQuery }}”</span>
|
||||
</div>
|
||||
|
||||
<div class="gt-tablewrap">
|
||||
<table class="gt-table">
|
||||
<thead>
|
||||
<tr><th>People group</th><th>Country</th><th>Region</th><th class="num">Population</th><th class="num">Reached</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pg in rows" :key="pg.slug">
|
||||
<td><span class="gt-dot" :class="statusOf(pg.reached)" aria-hidden="true"></span>{{ pg.name }}</td>
|
||||
<td>{{ pg.country }}</td>
|
||||
<td>{{ regionName(pg.region) }}</td>
|
||||
<td class="num">{{ fmtPop(pg.population) }}</td>
|
||||
<td class="num" :class="statusOf(pg.reached)">{{ pg.reached }}%</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length"><td colspan="5" class="gt-empty">No people groups match this filter.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.gt-root { width: 100%; height: 100%; display: flex; flex-direction: column;
|
||||
background: #0b0f14; color: #e5e7eb;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; box-sizing: border-box; }
|
||||
.gt-controls { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 12px;
|
||||
border-bottom: 1px solid #1f2937; }
|
||||
.gt-search { position: relative; display: flex; align-items: center; flex: 1; min-width: 180px; color: #94a3b8; }
|
||||
.gt-search svg { position: absolute; left: 9px; pointer-events: none; }
|
||||
.gt-search input { width: 100%; padding: 7px 10px 7px 30px; background: #111827;
|
||||
border: 1px solid #1f2937; border-radius: 8px; color: #e5e7eb; font-size: 13px; outline: none; }
|
||||
.gt-search input:focus { border-color: #8b5cf6; }
|
||||
.gt-controls select { padding: 7px 10px; background: #111827; border: 1px solid #1f2937;
|
||||
border-radius: 8px; color: #e5e7eb; font-size: 13px; outline: none; }
|
||||
.gt-controls select:focus { border-color: #8b5cf6; }
|
||||
.gt-summary { padding: 8px 12px; font-size: 12px; color: #94a3b8; border-bottom: 1px solid #1f2937; }
|
||||
.gt-summary strong { color: #8b5cf6; }
|
||||
.gt-tablewrap { flex: 1; min-height: 0; overflow: auto; }
|
||||
.gt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.gt-table thead th { position: sticky; top: 0; z-index: 1; text-align: left;
|
||||
padding: 8px 12px; background: #111827; color: #94a3b8; font: 700 11px/1 ui-monospace, monospace;
|
||||
letter-spacing: .06em; text-transform: uppercase; border-bottom: 1px solid #1f2937; }
|
||||
.gt-table th.num, .gt-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.gt-table tbody td { padding: 8px 12px; border-bottom: 1px solid #141c28; }
|
||||
.gt-table tbody tr:hover { background: #111827; }
|
||||
.gt-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 8px; vertical-align: middle; }
|
||||
.gt-dot.frontier, td.frontier { color: #ef4444; } .gt-dot.frontier { background: #ef4444; }
|
||||
.gt-dot.unreached, td.unreached { color: #f59e0b; } .gt-dot.unreached { background: #f59e0b; }
|
||||
.gt-dot.reached, td.reached { color: #10b981; } .gt-dot.reached { background: #10b981; }
|
||||
.gt-empty { padding: 20px; text-align: center; color: #64748b; }
|
||||
</style>
|
||||
|
|
@ -133,11 +133,18 @@ onMounted(loadOptions)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rh-root" :style="{ '--accent': accent }">
|
||||
<div class="rh-root" role="banner" :style="{ '--accent': accent }">
|
||||
<div class="rh-title">
|
||||
<span class="rh-dot" aria-hidden="true"></span>
|
||||
<h1>{{ title }}</h1>
|
||||
<span v-if="count !== null && count !== ''" class="rh-count">{{ count }} shown</span>
|
||||
<!-- aria-live=polite: a screen reader hears the live matched count change as the user
|
||||
searches/filters ("15 shown"). role=status keeps it an unobtrusive announcement. -->
|
||||
<span
|
||||
v-if="count !== null && count !== ''"
|
||||
class="rh-count"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>{{ count }} shown</span>
|
||||
</div>
|
||||
|
||||
<!-- SHARED SearchBar on top (replaces the hand-rolled <input type="search">). -->
|
||||
|
|
@ -158,7 +165,7 @@ onMounted(loadOptions)
|
|||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Block" name="wagf_block"
|
||||
label="Bloc" name="wagf_block"
|
||||
:options="options.wagf_block" :value="sel.wagf_block"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
|
|
@ -172,7 +179,13 @@ onMounted(loadOptions)
|
|||
:options="options.religion" :value="sel.religion"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<button v-if="hasAny" class="rh-reset" type="button" @click="reset">Reset</button>
|
||||
<button
|
||||
v-if="hasAny"
|
||||
class="rh-reset"
|
||||
type="button"
|
||||
aria-label="Reset all filters and search"
|
||||
@click="reset"
|
||||
>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
/**
|
||||
* people-groups.js — the Big Picture dataset (self-contained, token-free).
|
||||
*
|
||||
* Shared by BOTH inner elements of the "Big Picture" Cat-4 plugin:
|
||||
* - BpGroupsTable.vue (the Cat-2 people-groups PROP-TABLE) renders + filters this.
|
||||
* - BpResearchHeader.vue (the Cat-1 search/dropdown dashboard header) builds its
|
||||
* dropdown option lists (regions, reach-status) from this same catalogue.
|
||||
*
|
||||
* `region` + `status` are the join keys the header's dropdowns filter on; the header
|
||||
* pushes {query, region, status} DOWN to the table as props (or a host page drives the
|
||||
* table's own attributes directly). No remote fetch, no map token — renders instantly.
|
||||
*/
|
||||
|
||||
export const REGIONS = [
|
||||
{ id: 'na', name: 'North America' },
|
||||
{ id: 'sa', name: 'South America' },
|
||||
{ id: 'eu', name: 'Europe' },
|
||||
{ id: 'af', name: 'Africa' },
|
||||
{ id: 'me', name: 'Middle East' },
|
||||
{ id: 'ca', name: 'Central Asia' },
|
||||
{ id: 'sas', name: 'South Asia' },
|
||||
{ id: 'sea', name: 'Southeast Asia' },
|
||||
{ id: 'ea', name: 'East Asia' },
|
||||
]
|
||||
|
||||
// reach-status buckets (the second dropdown facet). `reached` is % believers proxy.
|
||||
export const STATUSES = [
|
||||
{ id: 'frontier', name: 'Frontier (<0.1%)' },
|
||||
{ id: 'unreached', name: 'Unreached (0.1–2%)' },
|
||||
{ id: 'reached', name: 'Reached (>2%)' },
|
||||
]
|
||||
|
||||
export function statusOf(reached) {
|
||||
return reached < 0.1 ? 'frontier' : reached < 2 ? 'unreached' : 'reached'
|
||||
}
|
||||
|
||||
export const PEOPLE_GROUPS = [
|
||||
{ slug: 'hui', name: 'Hui', country: 'China', region: 'ea', population: 11700000, reached: 0.1, jp: 3.1 },
|
||||
{ slug: 'uyghur', name: 'Uyghur', country: 'China', region: 'ca', population: 11500000, reached: 0.05, jp: 3.2 },
|
||||
{ slug: 'yadava', name: 'Yadava', country: 'India', region: 'sas', population: 60000000, reached: 0.2, jp: 3.0 },
|
||||
{ slug: 'shaikh', name: 'Shaikh', country: 'India', region: 'sas', population: 96000000, reached: 0.01, jp: 3.4 },
|
||||
{ slug: 'brahmin', name: 'Brahmin', country: 'India', region: 'sas', population: 58000000, reached: 0.3, jp: 2.8 },
|
||||
{ slug: 'bengali', name: 'Bengali', country: 'Bangladesh', region: 'sas', population: 148000000, reached: 0.4, jp: 2.9 },
|
||||
{ slug: 'javanese', name: 'Javanese', country: 'Indonesia', region: 'sea', population: 96000000, reached: 1.2, jp: 2.6 },
|
||||
{ slug: 'sundanese', name: 'Sundanese', country: 'Indonesia', region: 'sea', population: 38000000, reached: 0.1, jp: 3.2 },
|
||||
{ slug: 'thai', name: 'Central Thai', country: 'Thailand', region: 'sea', population: 22000000, reached: 0.5, jp: 2.8 },
|
||||
{ slug: 'persian', name: 'Persian', country: 'Iran', region: 'me', population: 39000000, reached: 0.4, jp: 2.9 },
|
||||
{ slug: 'arab-najdi', name: 'Najdi Arab', country: 'Saudi Arabia', region: 'me', population: 11000000, reached: 0.02, jp: 3.4 },
|
||||
{ slug: 'kurd', name: 'Kurd', country: 'Iraq', region: 'me', population: 8500000, reached: 0.1, jp: 3.1 },
|
||||
{ slug: 'turk', name: 'Turk', country: 'Turkey', region: 'me', population: 59000000, reached: 0.2, jp: 3.0 },
|
||||
{ slug: 'kazakh', name: 'Kazakh', country: 'Kazakhstan', region: 'ca', population: 13000000, reached: 0.3, jp: 2.9 },
|
||||
{ slug: 'uzbek', name: 'Uzbek', country: 'Uzbekistan', region: 'ca', population: 30000000, reached: 0.1, jp: 3.2 },
|
||||
{ slug: 'somali', name: 'Somali', country: 'Somalia', region: 'af', population: 17000000, reached: 0.05, jp: 3.3 },
|
||||
{ slug: 'fulani', name: 'Fulani', country: 'Nigeria', region: 'af', population: 38000000, reached: 0.2, jp: 3.1 },
|
||||
{ slug: 'hausa', name: 'Hausa', country: 'Nigeria', region: 'af', population: 65000000, reached: 0.3, jp: 3.0 },
|
||||
{ slug: 'berber', name: 'Shilha Berber', country: 'Morocco', region: 'af', population: 9000000, reached: 0.1, jp: 3.1 },
|
||||
{ slug: 'wolof', name: 'Wolof', country: 'Senegal', region: 'af', population: 6500000, reached: 0.1, jp: 3.2 },
|
||||
{ slug: 'tibetan', name: 'Tibetan', country: 'China', region: 'ea', population: 6300000, reached: 0.2, jp: 3.1 },
|
||||
{ slug: 'han-chinese',name: 'Han (Mandarin)', country: 'China', region: 'ea', population: 900000000, reached: 5.1, jp: 2.1 },
|
||||
{ slug: 'quechua', name: 'Quechua', country: 'Peru', region: 'sa', population: 8000000, reached: 3.0, jp: 2.3 },
|
||||
{ slug: 'roma', name: 'Roma', country: 'Romania', region: 'eu', population: 3000000, reached: 1.0, jp: 2.6 },
|
||||
{ slug: 'bosniak', name: 'Bosniak', country: 'Bosnia', region: 'eu', population: 2500000, reached: 0.4, jp: 2.9 },
|
||||
{ slug: 'navajo', name: 'Navajo', country: 'USA', region: 'na', population: 330000, reached: 4.0, jp: 2.2 },
|
||||
]
|
||||
|
||||
export const regionName = (id) => (REGIONS.find(r => r.id === id) || {}).name || id
|
||||
export const statusName = (id) => (STATUSES.find(s => s.id === id) || {}).name || id
|
||||
|
||||
/**
|
||||
* The ONE shared filter predicate. Both the header's live-count and the table use it, so
|
||||
* the count the header shows always matches what the table renders. Pure — no state.
|
||||
*/
|
||||
export function filterGroups({ query = '', region = '', status = '' } = {}) {
|
||||
const q = (query || '').trim().toLowerCase()
|
||||
return PEOPLE_GROUPS.filter((pg) => {
|
||||
if (region && pg.region !== region) return false
|
||||
if (status && statusOf(pg.reached) !== status) return false
|
||||
if (q && !(`${pg.name} ${pg.country}`.toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
|
@ -46,6 +46,10 @@
|
|||
|
||||
<script>
|
||||
window.UPG_TOKEN = (new URLSearchParams(location.search).get('tk')||(typeof window!=='undefined'&&window.MAPBOX_TOKEN)||'');
|
||||
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
|
||||
// as ?locale=. ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
|
||||
// the leaf keeps its own document.documentElement.lang detection.
|
||||
window.DOXA_LOCALE = (new URLSearchParams(location.search).get('locale') || '');
|
||||
</script>
|
||||
|
||||
<div class="stage-head">
|
||||
|
|
@ -69,11 +73,11 @@
|
|||
slot.innerHTML = '';
|
||||
slot.classList.toggle('is-wall', profile === 'connectivity-list');
|
||||
var el = document.createElement('cities-internet-map');
|
||||
el.setAttribute('profile-config', JSON.stringify({
|
||||
el.setAttribute('profile-config', JSON.stringify(Object.assign({
|
||||
profile: profile,
|
||||
tk: window.UPG_TOKEN || '',
|
||||
assetsBase: '/assets',
|
||||
}));
|
||||
}, window.DOXA_LOCALE ? { locale: window.DOXA_LOCALE } : {})));
|
||||
slot.appendChild(el);
|
||||
}
|
||||
document.querySelectorAll('.tab').forEach(function (b) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@
|
|||
|
||||
<script>
|
||||
window.UPG_TOKEN = (new URLSearchParams(location.search).get('tk')||(typeof window!=='undefined'&&window.MAPBOX_TOKEN)||'');
|
||||
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
|
||||
// as ?locale=. ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
|
||||
// the leaf keeps its own document.documentElement.lang detection.
|
||||
window.DOXA_LOCALE = (new URLSearchParams(location.search).get('locale') || '');
|
||||
</script>
|
||||
|
||||
<div class="stage-head">
|
||||
|
|
@ -69,11 +73,11 @@
|
|||
slot.innerHTML = '';
|
||||
slot.classList.toggle('is-wall', profile === 'country-poster-wall');
|
||||
var el = document.createElement('country-posters-map');
|
||||
el.setAttribute('profile-config', JSON.stringify({
|
||||
el.setAttribute('profile-config', JSON.stringify(Object.assign({
|
||||
profile: profile,
|
||||
tk: window.UPG_TOKEN || '',
|
||||
assetsBase: '/assets',
|
||||
}));
|
||||
}, window.DOXA_LOCALE ? { locale: window.DOXA_LOCALE } : {})));
|
||||
slot.appendChild(el);
|
||||
}
|
||||
document.querySelectorAll('.tab').forEach(function (b) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{ "_comment": "One default instance.", "instances": [ { "id": "default", "label": "Dashboard + Grid", "profileConfig": {} } ] }
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"_comment": "Capability-4 composite that BAKES the research-dashboard + people-groups-grid into ONE self-contained element (both components imported directly + compiled into a single bundle, NO runtime bundle injection — unlike stack-demo which composes via src). Rendered stacked via the shared StackLayout so you scroll through both. This is the 'two elements baked into one' element the Driver asked for.",
|
||||
"type": "nested-elements-mfe",
|
||||
"composes": ["research-dashboard", "people-groups-grid"],
|
||||
"label": "Dashboard + Grid (baked into one element, stacked to scroll)"
|
||||
}
|
||||
76
plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue
Normal file
76
plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script setup>
|
||||
/**
|
||||
* dashboard-plus-grid.vue — Capability-4 composite: research-dashboard + people-groups-grid
|
||||
* BAKED INTO ONE self-contained element, stacked to scroll.
|
||||
*
|
||||
* @element dashboard-plus-grid
|
||||
* @description Both children are IMPORTED DIRECTLY (compiled into this ONE bundle — no runtime bundle
|
||||
* injection, unlike stack-demo which pulls them via `src`). They render STACKED top-to-bottom via the
|
||||
* shared StackLayout, so the host page scrolls through the rich Research Dashboard (facets + map +
|
||||
* charts + table) and then the People Groups grid (live pray.doxa.life) as one continuous element.
|
||||
* This is the "two elements baked together into one" element — embed <dashboard-plus-grid> anywhere.
|
||||
*
|
||||
* Same "import the REAL components, no fork" discipline as big-picture: the grid is the exact
|
||||
* people-groups-grid component; the dashboard is the exact research-dashboard component.
|
||||
*/
|
||||
import StackLayout from '@map/layouts/StackLayout.vue'
|
||||
import ResearchDashboard from '../research-dashboard/research-dashboard.vue'
|
||||
import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
|
||||
|
||||
// One-screen height for the fixed-viewport research-dashboard band (see the section note below). Pinning
|
||||
// min === max gives the dashboard a DEFINITE height to fill (so its `1fr` map-hero + `height:100%` shell
|
||||
// resolve) while the OUTER page keeps growing below it → the grid is reachable by page scroll. `svh`
|
||||
// (small viewport height) avoids the mobile URL-bar jump; ~88% leaves the StackLayout top bar visible.
|
||||
const DASHBOARD_H = '88svh'
|
||||
|
||||
// Two host-rendered sections (no `element`/`src` — the components are baked in, so we render them
|
||||
// through StackLayout's per-section slots instead of the imperative element-mount path).
|
||||
const sections = [
|
||||
// The research-dashboard leaf SELF-TITLES (its own DashboardTopBar renders a "Research Dashboard"
|
||||
// title bar), so a StackLayout section title here would be a DUPLICATE — the Driver saw "Research
|
||||
// Dashboard" twice. Omit title (and sub, which StackLayout only shows alongside a title) → the leaf's
|
||||
// own header is the single source of truth for this section. See BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG.
|
||||
//
|
||||
// BUG-DASHBOARD-CANT-SCROLL-DOWN: the research-dashboard is a FIXED-VIEWPORT app — its shell is a CSS
|
||||
// grid with `height:100%` and a `1fr` map-hero plus inner-scroll (overflow:auto/hidden) regions. When
|
||||
// it renders in a scroll-page section with NO definite height, `height:100%` collapses to its 560px
|
||||
// floor and its inner-scroll regions swallow the wheel, so the OUTER page never grows past it → the
|
||||
// Driver couldn't scroll down to the grid. Fix: give this section a definite ONE-SCREEN height
|
||||
// (minHeight === maxHeight below via the composite's `dashboardHeight`). The `1fr` map now has real
|
||||
// height to fill, the dashboard is one clean scroll-page band, and the PAGE grows below it so the grid
|
||||
// is reachable by page scroll. `overflow:hidden` on the section keeps the dashboard's own inner scroll
|
||||
// (map/table/facets) from leaking a section scrollbar — the page owns the scroll, per the StackLayout
|
||||
// contract.
|
||||
{ key: 'dashboard', minHeight: DASHBOARD_H, maxHeight: DASHBOARD_H },
|
||||
// NO section title/sub (BUILD-BIGPIC-DROP-REDUNDANT-SECTION-HEADERS). The Driver does not want a
|
||||
// black element header that repeats the element's name or tells them to scroll — one header per
|
||||
// element, not two. StackLayout only renders .stl-sec-head when `title` is set, so omitting it
|
||||
// removes the strip entirely. The dashboard self-titles; the grid needs no label to be legible.
|
||||
{ key: 'grid' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- No `title` and no #meta: the layout's own top bar is omitted entirely when neither is used,
|
||||
so the composite shows ONLY the nested elements' own chrome. -->
|
||||
<StackLayout :sections="sections">
|
||||
<template #section-dashboard>
|
||||
<ResearchDashboard />
|
||||
</template>
|
||||
<template #section-grid>
|
||||
<!-- NO explainer line here. An earlier attempt added one as its own <p>, which the Driver
|
||||
correctly rejected: it created a SECOND header and re-introduced the vertical gap between
|
||||
the dashboard and the grid that we had just removed. The hint belongs INSIDE the table's
|
||||
existing header bar — centred, beside the live count — so it costs zero extra height.
|
||||
See DashboardDataTable's .rd-table-bar. -->
|
||||
<!-- The grid is DRIVEN by the research dashboard above it: the dashboard's header owns the
|
||||
search + filters, so the grid hides its own duplicates and takes external filter props.
|
||||
(BUILD-BIGPIC-GRID-DRIVEN-BY-DASHBOARD-PROPS-EVENTS.) -->
|
||||
<PeopleGroupsGrid :show-search="false" :show-filters="false" />
|
||||
</template>
|
||||
</StackLayout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:host { display:block; width:100%; }
|
||||
</style>
|
||||
37
plugin-designs/dashboard-plus-grid/index.html
Normal file
37
plugin-designs/dashboard-plus-grid/index.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · dashboard-plus-grid staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<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', system-ui, sans-serif; }
|
||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 28px 20px 48px; }
|
||||
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; max-width: 820px; }
|
||||
.sub code { color: #c4b5fd; }
|
||||
.host { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
|
||||
dashboard-plus-grid { display: block; width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>dashboard-plus-grid · staging</h1>
|
||||
<p class="sub">Capability 4 — the research-dashboard + people-groups-grid <strong>baked into ONE
|
||||
element</strong> (both components compiled into this single bundle, no runtime injection) and stacked
|
||||
via the shared <code>StackLayout</code>. Scroll down through both. Embed
|
||||
<code><dashboard-plus-grid></code> anywhere.</p>
|
||||
<div class="host">
|
||||
<dashboard-plus-grid></dashboard-plus-grid>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The GENERATED leaf pages set window.MAP_TOKEN from the baked build-config token (see
|
||||
generate-tree.mjs) so a baked composite's nested map finds a token. On this hand-written staging
|
||||
page (which the generator doesn't touch), a real host embedding <dashboard-plus-grid> would set
|
||||
window.MAP_TOKEN itself; the people-groups grid renders regardless (it needs no map token). -->
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
plugin-designs/dashboard-plus-grid/index.js
Normal file
51
plugin-designs/dashboard-plus-grid/index.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* dashboard-plus-grid / index.js — Bundle entry (Capability-4 nested-elements-mfe, BAKED variant).
|
||||
*
|
||||
* ONE folder -> ONE bundle -> ONE self-contained element:
|
||||
* <dashboard-plus-grid> — bakes the research-dashboard + people-groups-grid components DIRECTLY into
|
||||
* one compiled bundle (no runtime bundle injection) and stacks them via the shared StackLayout so the
|
||||
* host page scrolls through both. This is "two elements baked together into one."
|
||||
*
|
||||
* Usage — direct-JS (method 3):
|
||||
* <script src=".../dashboard-plus-grid/dashboard-plus-grid.js"></script>
|
||||
* <dashboard-plus-grid></dashboard-plus-grid>
|
||||
*/
|
||||
import { defineCustomElement } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import DashboardPlusGrid from './dashboard-plus-grid.vue'
|
||||
|
||||
// ─── configureApp is NOT optional for a BAKED composite ──────────────────────
|
||||
// BUG-VUE-I18N-CODE27-SYNTAXERROR-ON-EVERY-MOUNT. dashboard-plus-grid.vue imports
|
||||
// research-dashboard.vue DIRECTLY, which bypasses plugin-designs/research-dashboard/index.js
|
||||
// — and that entry, not the .vue, is where `app.use(createAppI18n())` lives. So the baked
|
||||
// library components that call useI18n() (ShareButton, ZoomInButton, ZoomOutButton) landed in
|
||||
// an appContext with no __VUE_I18N_SYMBOL__ and useI18n() threw I18nErrorCodes.NOT_INSTALLED
|
||||
// (runtime code 27 — "Need to install with `app.use` function").
|
||||
//
|
||||
// The throw surfaces as a bare `SyntaxError` with an EMPTY message, which is badly misleading:
|
||||
// vue-i18n builds every error through the message-compiler's createCompileError(), which does
|
||||
// `new SyntaxError(...)`, and in a production build it passes no message table at all. Nothing
|
||||
// is being parsed and nothing is being minified — read the CODE, not the error class.
|
||||
//
|
||||
// It was never cosmetic: setup() threw, so Vue aborted those three subtrees and the composite
|
||||
// silently shipped WITHOUT its Share / Zoom in / Zoom out map controls, which cap-3 has.
|
||||
//
|
||||
// Installing the same Pinia + i18n pair the sibling entries install makes the baked children
|
||||
// see exactly the appContext they would get when mounted through their own bundle entry.
|
||||
const DashboardPlusGridElement = defineCustomElement(DashboardPlusGrid, {
|
||||
configureApp(app) {
|
||||
app.use(createPinia())
|
||||
const i18n = createAppI18n()
|
||||
app.use(i18n)
|
||||
// Same contract as the map bundles: the defineCustomElement ROOT cannot call useI18n()
|
||||
// (see ProfileLoader.vue), so any root-level locale driving goes through this instance.
|
||||
app.provide('appI18n', i18n)
|
||||
}
|
||||
})
|
||||
|
||||
if (typeof customElements !== 'undefined') {
|
||||
if (!customElements.get('dashboard-plus-grid')) customElements.define('dashboard-plus-grid', DashboardPlusGridElement)
|
||||
}
|
||||
|
||||
export default DashboardPlusGridElement
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
<VerticalTabsLayout
|
||||
:title="title"
|
||||
nav-label="Research maps"
|
||||
:header="showHeader"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
@update:active="onSelect">
|
||||
|
|
@ -48,21 +49,24 @@
|
|||
external asset, offline/F-Droid safe; the accent green matches the admin shell). -->
|
||||
<template #brand>
|
||||
<span class="dms-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#00DC82" stroke-width="2"
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#92b195" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polygon points="12 6 14 12 12 18 10 12" fill="#00DC82" stroke="none" />
|
||||
<polygon points="6 12 12 10 18 12 12 14" fill="#00DC82" opacity="0.55" stroke="none" />
|
||||
<polygon points="12 6 14 12 12 18 10 12" fill="#92b195" stroke="none" />
|
||||
<polygon points="6 12 12 10 18 12 12 14" fill="#92b195" opacity="0.55" stroke="none" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #meta>{{ tabs.length }} maps</template>
|
||||
|
||||
<!-- #toolbar: the i18n locale switcher (3-dots/flag). Reuses app-wrapper-material/languages.js —
|
||||
renders the flag + native name, persists the choice (loadLocale/saveLocale), and re-sets the
|
||||
mounted tab-1 element's profile-config.locale (languages.js's contract). No routing, no dep. -->
|
||||
<template #toolbar>
|
||||
<!-- #toolbar: the i18n locale switcher (3-dots/flag). GATED by showLocaleSwitcher — the cap-5
|
||||
DX-Maps SPA has NO standalone language dropdown by default; it INHERITS its language from the
|
||||
parent page via the `language` prop (see effectiveLocale). The dropdown only shows when the
|
||||
header is ON (a host explicitly opts the SPA into its own chrome). Reuses
|
||||
app-wrapper-material/languages.js — persists the choice + re-sets the mounted tab-1 element's
|
||||
profile-config.locale (languages.js's contract). No routing, no dep. -->
|
||||
<template #toolbar v-if="showLocaleSwitcher">
|
||||
<div class="dms-locale">
|
||||
<button type="button" class="dms-locale-btn" :aria-expanded="String(localeOpen)"
|
||||
aria-label="Change language" @click="localeOpen = !localeOpen">
|
||||
|
|
@ -128,32 +132,84 @@ import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/la
|
|||
// exist in this SPA are OMITTED (never paint a dead nav row — nav.js's own navForTarget prunes empty
|
||||
// groups the same way). `folder:true` (Experimental) renders collapsible, starting closed.
|
||||
const NAV_MODEL = [
|
||||
// Dashboards — Big Picture stays the FIRST/default tab (its dashboard→grid coupling is the landing
|
||||
// surface). Matches nav.js's `dashboards` section; kept top so tab-1 remains Big Picture.
|
||||
{ key: 'dashboards', label: 'Dashboards', icon: 'monitor',
|
||||
tabs: [ { id: 'big-picture', icon: 'chart' } ] },
|
||||
// ── NOTE: there is deliberately NO separate "Dashboards" section. The ONE dashboard (the Driver's
|
||||
// ruling: "we don't need multiple dashboards") is `dashboard-plus-grid` — the coupled
|
||||
// research-dashboard + people-groups-grid on a single scroll-page, what the Driver calls the Big
|
||||
// Picture. It is the FIRST row of Prayer Mobilization below, which makes it tab-1/the landing
|
||||
// surface AND the opening step of the onboarding ramp.
|
||||
//
|
||||
// WHY IT IS NOT ALSO ITS OWN SECTION: it used to be, and that is exactly what painted "Big
|
||||
// Picture" TWICE in the rail (a one-row "Dashboards" section whose single row was re-declared as
|
||||
// the ramp's step 1). Two rows sharing one tab id is not merely cosmetic — the layout keys its
|
||||
// tab-ref map and its `active` class BY ID, so both copies highlight at once and keyboard nav
|
||||
// stalls on the repeated key. One section owning the row fixes all of that and matches the
|
||||
// Driver's stated order (Big Picture → UPG-100 → Prayer Progress → Gospel Resources → Research).
|
||||
//
|
||||
// Prayer Mobilization FIRST among the map sections (the prayer map is the mobilisation landing).
|
||||
//
|
||||
// ORDER IS THE DRIVER'S, AND THE ORDER IS THE TEACHING. Big Picture → UPG-100 List → Prayer
|
||||
// Progress → Gospel Resources. The non-obvious step is UPG-100 BEFORE the prayer map, and his
|
||||
// rationale is worth preserving verbatim: "if you're trying to mobilize prayer, UPG-100 list
|
||||
// probably comes first because it's a NEW thing to people — you want them introduced to it
|
||||
// first." i.e. the rail is an ONBOARDING RAMP, not an inventory: show the big picture, hand the
|
||||
// newcomer the concrete new artifact (the 100 list), THEN ask them to pray it, then give them
|
||||
// the resources to act. Re-sorting these rows by any other logic (alphabetical, "maps first",
|
||||
// most-used) silently breaks that ramp — so do not reorder without the Driver.
|
||||
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart',
|
||||
tabs: [
|
||||
{ id: 'prayer-map', icon: 'embed' }, // Prayer Progress = the world prayer map
|
||||
{ id: 'upg-100', icon: 'list' }, // My UPG 100 List
|
||||
{ id: 'dashboard-plus-grid', icon: 'chart' }, // 1. Big Picture — orient first
|
||||
{ id: 'upg-100', icon: 'list' }, // 2. UPG-100 List — the NEW thing, introduced early
|
||||
{ id: 'prayer-map', icon: 'embed' }, // 3. Prayer Progress — now pray the list
|
||||
{ id: 'gospel-resources', icon: 'book' }, // 4. Gospel Resources — then act
|
||||
] },
|
||||
// Research — a peer section (nav.js: "a section to the same degree as Laborer").
|
||||
// Research — a peer section (nav.js: "a section to the same degree as Laborer"). The Driver's
|
||||
// order: Cities & Internet FIRST, then Gospel Resources, then the simple research app at the
|
||||
// bottom.
|
||||
//
|
||||
// ★ Gospel Resources is DECLARED here as well as in the prayer ramp above, on purpose — it is the
|
||||
// "then act" step of that ramp AND a research surface in its own right. It is declared twice but
|
||||
// RENDERED ONCE: dedupeRows() (see below) gives the FIRST declaration the row and drops this
|
||||
// later one, so the rail holds exactly one node per tab id. Leaving the declaration here is not
|
||||
// dead weight — it documents the intent, and if the prayer section is ever re-cut this section
|
||||
// inherits the row automatically.
|
||||
{ key: 'research', label: 'Research', icon: 'search',
|
||||
tabs: [
|
||||
{ id: 'research-map', icon: 'map' },
|
||||
{ id: 'cities-internet', icon: 'wifi' },
|
||||
{ id: 'gospel-resources', icon: 'book' },
|
||||
{ id: 'research-map', icon: 'map' }, // the simple research app, at the bottom
|
||||
] },
|
||||
// Experimental — a collapsible FOLDER at the bottom (nav.js: kind 'folder', open:false). Holds the
|
||||
// real lab maps (built bundles with their own leaf pages). Starts CLOSED; click the heading to open.
|
||||
{ key: 'experimental', label: 'Experimental', icon: 'flask', folder: true, open: false,
|
||||
tabs: [
|
||||
{ id: 'all-people-groups', icon: 'globe' },
|
||||
{ id: 'country-posters', icon: 'poster', badge: 'lab' },
|
||||
] },
|
||||
// Experimental — a collapsible FOLDER at the bottom (nav.js: kind 'folder', open:false). Starts
|
||||
// CLOSED; click the heading to open.
|
||||
//
|
||||
// ★ GENERIC BY CONSTRUCTION — no hardcoded row list. Its `tabs` is deliberately EMPTY here and is
|
||||
// filled at runtime by discoverExperimental() from the BUILT manifest.json: every bundle that
|
||||
// exists in the tree but is not placed in a named section above lands here automatically. Add a
|
||||
// new plugin to plugin-designs/ and it appears in this folder with ZERO edits to this file.
|
||||
// WHY this matters: a hardcoded list fails SILENTLY — the next plugin author ships a bundle, it
|
||||
// builds fine, and it is simply invisible in the app with no error to notice. Same principle the
|
||||
// Driver set for the data bundler: handle every plugin generically, or the next author gets
|
||||
// nothing. The named sections above stay curated (they encode the teaching order); Experimental
|
||||
// is the automatic catch-all so nothing can go missing.
|
||||
{ key: 'experimental', label: 'Experimental', icon: 'flask', folder: true, open: false, tabs: [] },
|
||||
]
|
||||
|
||||
// Bundles that are NOT candidates for auto-discovery into Experimental, with the reason each is
|
||||
// excluded. Everything else the manifest reports becomes an Experimental tab automatically.
|
||||
// - doxa-maps-spa : this shell itself (nesting the app inside its own tab = infinite regress)
|
||||
// - plugin-studio : the authoring/dev tool, not an end-user map
|
||||
// - template-bundle: the copy-me scaffold for new plugin authors, not real content
|
||||
// - people-groups-grid / research-dashboard / big-picture: the PARTS that are already composed
|
||||
// into the ONE `dashboard-plus-grid` Big Picture tab — surfacing them separately is the exact
|
||||
// redundancy the Driver removed ("we don't need multiple dashboards").
|
||||
const NOT_EXPERIMENTAL = new Set([
|
||||
'doxa-maps-spa',
|
||||
'plugin-studio',
|
||||
'template-bundle',
|
||||
'people-groups-grid',
|
||||
'research-dashboard',
|
||||
'big-picture'
|
||||
])
|
||||
|
||||
// The tab manifest. Each entry names a real, built bundle in this tree and how to nest it.
|
||||
// mode 'element' → inject <bundleJs> once, then create <tag> (direct-JS; token-free maps only).
|
||||
// mode 'iframe' → point an iframe at the bundle's standalone leaf page (its own SPA).
|
||||
|
|
@ -161,10 +217,13 @@ const NAV_MODEL = [
|
|||
// iframeUrl/scriptUrl shape ("./<bundle>/<profile>/index.html", "./<bundle>/<bundle>.js").
|
||||
const TAB_DEFS = [
|
||||
{
|
||||
id: 'big-picture', label: 'Big Picture', sub: 'search + people groups', emoji: '🌍',
|
||||
mode: 'element', tag: 'bp-big-picture',
|
||||
bundleJs: 'big-picture/big-picture.js',
|
||||
leaf: 'big-picture/big-picture/index.html'
|
||||
// THE ONE DASHBOARD (the Driver's "Big Picture"): research-dashboard + people-groups-grid compiled
|
||||
// into ONE element on a single scroll-page — scroll down past the dashboard to the grid. Replaces the
|
||||
// former separate `big-picture` and `research-dashboard` tabs (removed as redundant). Its generated
|
||||
// leaf sets window.MAP_TOKEN from the baked token so the nested map paints.
|
||||
id: 'dashboard-plus-grid', label: 'Big Picture', sub: 'dashboard + people-groups grid, stacked', emoji: '🌍',
|
||||
mode: 'iframe', tag: 'dashboard-plus-grid',
|
||||
leaf: 'dashboard-plus-grid/dashboard-plus-grid/index.html'
|
||||
},
|
||||
{
|
||||
id: 'research-map', label: 'Research Map', sub: 'prayer · engagement · regions', emoji: '🗺️',
|
||||
|
|
@ -172,7 +231,13 @@ const TAB_DEFS = [
|
|||
leaf: 'doxa-research-map/research-map/index.html'
|
||||
},
|
||||
{
|
||||
id: 'prayer-map', label: 'Prayer Map', sub: 'simple world prayer map', emoji: '🙏',
|
||||
// RENAMED (Driver): "Prayer Map" → "Prayer Progress". His words: "the prayer maps can be renamed
|
||||
// Prayer Progress Map, or just Prayer Progress, and it can sit UNDERNEATH UPG-100 List." The
|
||||
// shorter form won — the rail is narrow and "Progress" already implies the map. NOTE the `id`
|
||||
// stays 'prayer-map': the id is the stable join key between NAV_MODEL, TAB_DEFS and the built
|
||||
// leaf path, so only the human-facing LABEL changes. Renaming the id would break the leaf lookup
|
||||
// for no user-visible gain.
|
||||
id: 'prayer-map', label: 'Prayer Progress', sub: 'simple world prayer map', emoji: '🙏',
|
||||
mode: 'iframe', tag: 'doxa-simple-map',
|
||||
// `prayer` is an INSTANCE (not a profile), so its built leaf lives one level deeper under the
|
||||
// bundle dir: doxa-simple-map/doxa-simple-map/prayer/ (see manifest.json). The shallow path 404s.
|
||||
|
|
@ -193,20 +258,123 @@ const TAB_DEFS = [
|
|||
mode: 'iframe', tag: 'my-upg-100-list-map',
|
||||
leaf: 'my-upg-100-list/upg-100-mfe/index.html'
|
||||
},
|
||||
// ── Experimental lab maps (real, built bundles with standalone leaf pages) — surfaced under the
|
||||
// collapsible Experimental folder. Kept lazy iframes like the other maps.
|
||||
{
|
||||
id: 'all-people-groups', label: 'All People Groups', sub: 'the full global set (~15k)', emoji: '🌐',
|
||||
mode: 'iframe', tag: 'all-people-groups',
|
||||
leaf: 'all-people-groups/all-people-groups/index.html'
|
||||
},
|
||||
{
|
||||
id: 'country-posters', label: 'Country Posters', sub: 'printable country prayer sheets', emoji: '🖼️',
|
||||
mode: 'iframe', tag: 'country-posters-map',
|
||||
leaf: 'country-posters/countries-map/index.html'
|
||||
}
|
||||
// ── NOTE: there are deliberately NO hardcoded Experimental entries here. Every built bundle not
|
||||
// named above is discovered from manifest.json at runtime and appended as an Experimental tab
|
||||
// (see discoverExperimental + NOT_EXPERIMENTAL). That is what keeps a NEW plugin visible with
|
||||
// zero edits to this file.
|
||||
]
|
||||
|
||||
// The set of bundle ids the CURATED sections above already place, derived from NAV_MODEL + TAB_DEFS
|
||||
// rather than hand-written. Deriving it means the "is this already placed?" answer can never drift
|
||||
// out of sync with the actual rail — the classic failure mode of a parallel hardcoded list.
|
||||
function curatedBundles() {
|
||||
const byId = Object.fromEntries(TAB_DEFS.map(t => [t.id, t]))
|
||||
const out = new Set()
|
||||
for (const g of NAV_MODEL) {
|
||||
if (g.key === 'experimental') continue // the catch-all places nothing by definition
|
||||
for (const r of g.tabs || []) {
|
||||
const t = byId[r.id]
|
||||
if (t) out.add(t.bundle || String(t.leaf).split('/')[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* THE RAIL'S ONE ENUMERATION RULE — one row per TAB ID, first declaration wins.
|
||||
*
|
||||
* ★ THE RULE, stated once so the next plugin author inherits it deliberately rather than by accident:
|
||||
* a BUNDLE contributes exactly ONE rail row. Its profiles (gospel-resources ships charts /
|
||||
* data-tables / resources-list / resources-map; big-picture ships instances) are alternate VIEWS of
|
||||
* that one map and are reachable INSIDE the pane — they are NOT promoted to rail rows. The rail is
|
||||
* an onboarding ramp, not an inventory: per-profile rows would add four rows for gospel-resources
|
||||
* alone and bury the teaching order the sections above encode.
|
||||
*
|
||||
* WHY A FUNCTION AND NOT JUST A CAREFULLY-WRITTEN NAV_MODEL: sections are allowed to DECLARE the same
|
||||
* tab twice on purpose (Gospel Resources is both the prayer ramp's "then act" step and a research
|
||||
* surface). Declaring twice is expressive; RENDERING twice is a bug. This keeps the first declaration
|
||||
* — which is what makes the curated order authoritative — and silently drops later ones, so the two
|
||||
* concerns stop fighting.
|
||||
*
|
||||
* WHY IT IS A CORRECTNESS FIX, NOT COSMETICS: VerticalTabsLayout keys its tab-element map by row key
|
||||
* (`registerTab(row.key, el)`) and computes highlight as `row.key === active`. Two rows sharing an id
|
||||
* means the second node overwrites the first in that map (so roving-focus can only ever reach one),
|
||||
* BOTH copies render as active simultaneously, and `orderedKeys` visits the duplicate twice — arrow
|
||||
* keys appear to stall. De-duplicating by id restores all three invariants at once.
|
||||
*
|
||||
* Generic by construction: nothing here names a bundle, so it holds for every plugin added later.
|
||||
*/
|
||||
function dedupeRows(rows, seen) {
|
||||
const out = []
|
||||
for (const r of rows) {
|
||||
if (!r || seen.has(r.key)) continue // a later section yields to the first declaration
|
||||
seen.add(r.key)
|
||||
out.push(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Turn a bundle id into a human label: "all-people-groups" → "All People Groups". Small, local and
|
||||
// dependency-free; a bundle may override it by shipping a profile `title` in the manifest.
|
||||
function titleize(id) {
|
||||
return String(id).split(/[-_]/).filter(Boolean)
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* GENERIC EXPERIMENTAL DISCOVERY — the whole point of Card 2.
|
||||
*
|
||||
* Reads the BUILT manifest.json (the authoritative list of what actually exists in this tree — the
|
||||
* same file the gallery and every bundle page read) and returns a tab def for every bundle that is
|
||||
* (a) not already placed in a curated section and (b) not in NOT_EXPERIMENTAL. Nothing about any
|
||||
* specific plugin is encoded here, so a brand-new bundle shows up on the next build by itself.
|
||||
*
|
||||
* WHY THE MANIFEST AND NOT A DIRECTORY LISTING: manifest.json is generated by the build and already
|
||||
* honors `gatedProfiles` (e.g. key-cities is intentionally withheld), so reading it inherits every
|
||||
* publishing decision for free. A raw listing of the served folder would resurrect gated/stale
|
||||
* directories as phantom tabs.
|
||||
*
|
||||
* WHY RUNTIME AND NOT BUILD-TIME: baking the list in at compile time would just move the hardcoding
|
||||
* one layer down — the shell would still ship a frozen list. Fetching at runtime means the SAME
|
||||
* built shell adapts to whatever tree it is dropped into. manifest.json is inside the drop-in
|
||||
* allowlist, so it travels with the tree everywhere the shell does.
|
||||
*
|
||||
* FAIL-SOFT: if the manifest is unreachable (offline file:// drop, a host that did not ship it), we
|
||||
* return [] and the Experimental folder simply stays empty. The curated sections are unaffected —
|
||||
* a discovery failure must never take down the app's main rail.
|
||||
*/
|
||||
function experimentalTabsFromManifest(manifest) {
|
||||
const placed = curatedBundles()
|
||||
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
|
||||
const out = []
|
||||
for (const b of bundles) {
|
||||
const id = b && b.bundle
|
||||
if (!id || placed.has(id) || NOT_EXPERIMENTAL.has(id)) continue
|
||||
// Pick the bundle's entry profile: prefer a real MAP profile, else the profile named like the
|
||||
// bundle, else simply the first one. Every profile in the manifest carries its own built leaf
|
||||
// page (iframeUrl), which is exactly what an iframe tab needs.
|
||||
const profiles = Array.isArray(b.profiles) ? b.profiles : []
|
||||
if (!profiles.length) continue
|
||||
const entry = profiles.find(p => p.isMap) || profiles.find(p => p.profile === id) || profiles[0]
|
||||
const leaf = String(entry.iframeUrl || '').replace(/^\.\//, '')
|
||||
if (!leaf) continue
|
||||
out.push({
|
||||
id, // bundle id doubles as the stable tab id
|
||||
label: entry.title || titleize(id),
|
||||
sub: (entry.description || '').trim().replace(/[,;]\s*$/, '') || 'experimental bundle',
|
||||
emoji: '🧪',
|
||||
mode: 'iframe',
|
||||
tag: (Array.isArray(b.elements) && b.elements[0]) || id,
|
||||
bundle: id,
|
||||
leaf,
|
||||
discovered: true // marks an auto-discovered tab (used by the rail + tests)
|
||||
})
|
||||
}
|
||||
// Stable, predictable order so the folder does not reshuffle between builds.
|
||||
out.sort((a, z) => a.label.localeCompare(z.label))
|
||||
return out
|
||||
}
|
||||
|
||||
// Load a script exactly once per URL (mirrors the gallery's once()). Resolves when the IIFE has
|
||||
// executed (so its customElements.define has run). SRI is optional (attrs.integrity).
|
||||
const _loaded = {}
|
||||
|
|
@ -236,7 +404,15 @@ export default defineComponent({
|
|||
title: { type: String, default: 'DX-Maps' },
|
||||
// Optional: pin the Mapbox token URL passed down to iframe leaf pages via ?tokenUrl / ?tk.
|
||||
tokenUrl: { type: String, default: '' },
|
||||
tk: { type: String, default: '' }
|
||||
tk: { type: String, default: '' },
|
||||
// LOCALIZATION PROP (loc architecture). The SPA has NO standalone language dropdown by default;
|
||||
// it INHERITS its language from the parent page via this `language` prop (canonical name). When a
|
||||
// cap-5 header hosts a dropdown it sets this prop for all tabs; otherwise the host page passes it
|
||||
// down. `locale`/`lang` are also accepted via profile-config (ProfileLoader's contract) — see
|
||||
// effectiveLocale below, which prefers this prop, then profile-config, then the manual switcher.
|
||||
// NOTE: no `locale` PROP here on purpose — `locale` is a data() field (the manual switcher's
|
||||
// state); a same-named prop+data collides in Vue. The alias lives in profile-config instead.
|
||||
language: { type: String, default: '' }
|
||||
},
|
||||
setup() {
|
||||
// index.js provides the tree base it derived from the running <script> URL. Prop/attr override
|
||||
|
|
@ -247,6 +423,12 @@ export default defineComponent({
|
|||
data() {
|
||||
return {
|
||||
activeIndex: 0,
|
||||
// The cap-5 DX-Maps SPA is HEADER-OFF by default (sidebar-only, full-page — no stacked headers
|
||||
// when embedded under a host that already has one). This ONE flag drives BOTH the shell header
|
||||
// and the standalone locale dropdown, so the Driver's rule ("header-off → no dropdown; the SPA
|
||||
// inherits its language from the parent via the `language` prop") can never drift: see
|
||||
// showLocaleSwitcher. A future host that wants the SPA's own chrome flips this to true.
|
||||
showHeader: false,
|
||||
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
||||
locale: loadLocale(),
|
||||
localeOpen: false,
|
||||
|
|
@ -284,20 +466,52 @@ export default defineComponent({
|
|||
// dropped, and a group with no surviving rows is omitted (never paint a dead section/row).
|
||||
navGroups() {
|
||||
const byId = Object.fromEntries(this.tabs.map(t => [t.id, t]))
|
||||
// ONE `seen` set threaded across ALL groups (not per-group): the rail must hold one node per tab
|
||||
// id GLOBALLY, since the layout's tab-ref map and `active` highlight are keyed by id across the
|
||||
// whole rail. A per-group set would still let two sections each paint the same tab. See
|
||||
// dedupeRows() for the rule and why duplicates are a correctness bug, not a cosmetic one.
|
||||
const seen = new Set()
|
||||
return NAV_MODEL
|
||||
.map(g => {
|
||||
const rows = (g.tabs || [])
|
||||
.filter(r => byId[r.id])
|
||||
.map(r => {
|
||||
const t = byId[r.id]
|
||||
return { key: t.id, label: t.label, sub: t.sub, icon: r.icon, badge: r.badge }
|
||||
})
|
||||
let rows = dedupeRows(
|
||||
(g.tabs || [])
|
||||
.filter(r => byId[r.id])
|
||||
.map(r => {
|
||||
const t = byId[r.id]
|
||||
return { key: t.id, label: t.label, sub: t.sub, icon: r.icon, badge: r.badge }
|
||||
}),
|
||||
seen)
|
||||
// The Experimental folder is not authored — it is FILLED with every discovered tab (any
|
||||
// built bundle no curated section placed). This is the one group whose rows come from the
|
||||
// manifest rather than NAV_MODEL, which is what makes a new plugin appear by itself.
|
||||
if (g.key === 'experimental') {
|
||||
// Discovered rows go through the SAME de-dup gate as curated ones. Belt-and-braces: a
|
||||
// discovered bundle should already be excluded by curatedBundles()/NOT_EXPERIMENTAL, but
|
||||
// routing it through `seen` means even a mis-declared bundle can only ever paint one row.
|
||||
rows = rows.concat(dedupeRows(
|
||||
this.tabs.filter(t => t.discovered)
|
||||
.map(t => ({ key: t.id, label: t.label, sub: t.sub, icon: 'flask', badge: 'lab' })),
|
||||
seen))
|
||||
}
|
||||
return { key: g.key, label: g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
|
||||
})
|
||||
.filter(g => g.rows.length)
|
||||
},
|
||||
// Whether the standalone #toolbar locale dropdown renders. The Driver's rule: the cap-5 SPA has
|
||||
// NO standalone dropdown by default — it INHERITS its language from the parent via the `language`
|
||||
// prop (effectiveLocale). The dropdown is only offered when the header is ON (a host opting the
|
||||
// SPA into its own chrome). Keyed to the SAME flag as the shell header so the two never diverge.
|
||||
showLocaleSwitcher() { return this.showHeader },
|
||||
// The active language's flag/native name for the toolbar toggle button.
|
||||
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } },
|
||||
// The EFFECTIVE preview language pushed down to every tab. Precedence, most-authoritative first:
|
||||
// 1. `language` prop / profile-config.language|locale|lang — the parent-page (inherited) value.
|
||||
// 2. the manual switcher's data.locale (only meaningful when a header dropdown is present).
|
||||
// A tab (direct element or iframe) always receives THIS, so the SPA honors one language prop.
|
||||
effectiveLocale() {
|
||||
const fromProp = (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
|
||||
return (fromProp || this.locale || '').split('-')[0].toLowerCase()
|
||||
},
|
||||
activeKey() {
|
||||
const t = this.tabs[this.activeIndex]
|
||||
return t ? t.id : ''
|
||||
|
|
@ -307,9 +521,59 @@ export default defineComponent({
|
|||
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
|
||||
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
|
||||
this.select(0)
|
||||
// …then discover the Experimental folder's contents in the background. Deliberately AFTER
|
||||
// select(0) and never awaited: the curated rail must paint instantly and must not depend on a
|
||||
// network read succeeding.
|
||||
this.loadExperimental()
|
||||
},
|
||||
watch: {
|
||||
// When the parent page changes the inherited `language` prop at runtime, re-drive the CURRENTLY
|
||||
// visible tab into the new language: re-set a direct element's profile-config.locale, or reload an
|
||||
// iframe pane's src (which carries ?locale=). Other tabs pick it up lazily on their next open,
|
||||
// since select()/mountElement() read effectiveLocale fresh. Keeps the SPA honoring one prop.
|
||||
effectiveLocale(loc) {
|
||||
const t = this.tabs[this.activeIndex]
|
||||
if (!t) return
|
||||
if (t.mode === 'iframe' && t.loaded) {
|
||||
t.src = this.withToken(this.resolve(t.leaf)) // re-point → nested SPA re-mounts in the new lang
|
||||
} else if (t.mode === 'element' && t.mounted) {
|
||||
try {
|
||||
const slot = this._slots && this._slots[this.activeIndex]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute) {
|
||||
const pc = { profile: t.id, instanceId: 'dms-' + t.id }
|
||||
if (loc) pc.locale = loc
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
}
|
||||
} catch { /* non-fatal: the tab keeps its previous language */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
|
||||
// Fetch the built manifest and append a tab for every bundle no curated section placed, so the
|
||||
// Experimental folder fills itself. Fail-soft by design: any error (no manifest shipped, a
|
||||
// file:// drop where fetch is blocked, malformed JSON) leaves the folder empty and the curated
|
||||
// rail untouched — discovery is an enhancement, never a dependency.
|
||||
loadExperimental() {
|
||||
const url = this.resolve('manifest.json')
|
||||
try {
|
||||
fetch(url, { credentials: 'omit' })
|
||||
.then(r => (r && r.ok) ? r.json() : null)
|
||||
.then(m => {
|
||||
if (!m) return
|
||||
const extra = experimentalTabsFromManifest(m)
|
||||
if (!extra.length) return
|
||||
const have = new Set(this.tabs.map(t => t.id))
|
||||
for (const d of extra) {
|
||||
if (have.has(d.id)) continue
|
||||
this.tabs.push({ ...d, loaded: false, mounted: false, state: 'idle', error: '', src: '' })
|
||||
}
|
||||
})
|
||||
.catch(() => { /* non-fatal: Experimental simply stays empty */ })
|
||||
} catch { /* fetch unavailable (non-browser): same fail-soft path */ }
|
||||
},
|
||||
// Switch UI language. languages.js's contract: switching = re-setting profile-config.locale on
|
||||
// the mounted element (ProfileLoader reads it, drives the bundle's i18n). We persist the choice
|
||||
// and best-effort re-set the locale on tab-1's mounted <bp-big-picture> (the only direct element;
|
||||
|
|
@ -361,6 +625,12 @@ export default defineComponent({
|
|||
const q = []
|
||||
if (tk) q.push('tk=' + encodeURIComponent(tk))
|
||||
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
|
||||
// Push the effective language down to the nested SPA as `?locale=` — the map leaf pages read it
|
||||
// and inject it into their element's profile-config.locale (ProfileLoader's contract). Only when
|
||||
// a concrete locale is set, so a plain drop keeps the leaf's own document.documentElement.lang
|
||||
// detection.
|
||||
const loc = this.effectiveLocale
|
||||
if (loc) q.push('locale=' + encodeURIComponent(loc))
|
||||
if (!q.length) return url
|
||||
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
|
||||
},
|
||||
|
|
@ -378,6 +648,9 @@ export default defineComponent({
|
|||
slot.innerHTML = ''
|
||||
const el = document.createElement(t.tag)
|
||||
const pc = { profile: t.id, instanceId: 'dms-' + t.id }
|
||||
// Honor the effective language on first mount too (not only on a later manual switch), so a
|
||||
// tab opened with an inherited `language` prop already renders in that language.
|
||||
if (this.effectiveLocale) pc.locale = this.effectiveLocale
|
||||
// Big Picture is token-free; only forward a token if the host gave one.
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
|
|
@ -399,10 +672,10 @@ export default defineComponent({
|
|||
direct-JS slot / lazy iframe / loading hint each fill their pane. */
|
||||
.dms-pane { position: absolute; inset: 0; }
|
||||
.dms-slot { position: absolute; inset: 0; }
|
||||
.dms-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #0b0f14; }
|
||||
.dms-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #f3f3f1; }
|
||||
.dms-hint {
|
||||
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
|
||||
color: #94a3b8; font-size: 13px; padding: 20px;
|
||||
color: #6d766e; font-size: 13px; padding: 20px;
|
||||
}
|
||||
.dms-hint.err { color: #f87171; }
|
||||
.dms-hint small { color: #64748b; }
|
||||
|
|
@ -415,25 +688,25 @@ export default defineComponent({
|
|||
.dms-locale { position: relative; padding: 8px 12px 0; display: flex; justify-content: flex-end; }
|
||||
.dms-locale-btn {
|
||||
display: inline-flex; align-items: center; gap: 8px; font: inherit; font-size: 12px;
|
||||
padding: 5px 10px; border: 1px solid #404040; border-radius: 8px; background: #262626;
|
||||
color: #f5f5f5; cursor: pointer;
|
||||
padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd;
|
||||
color: #3b463d; cursor: pointer;
|
||||
}
|
||||
.dms-locale-btn:hover { background: #404040; }
|
||||
.dms-locale-btn:hover { background: #d1d4cd; }
|
||||
.dms-flag { font-size: 14px; line-height: 1; }
|
||||
.dms-locale-name { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dms-dots { color: #a3a3a3; margin-left: 2px; }
|
||||
.dms-dots { color: #6d766e; margin-left: 2px; }
|
||||
.dms-locale-menu {
|
||||
position: absolute; top: 100%; right: 12px; margin-top: 4px; z-index: 30; min-width: 180px;
|
||||
list-style: none; padding: 6px; border: 1px solid #404040; border-radius: 10px;
|
||||
background: #1f1f1f; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto;
|
||||
list-style: none; padding: 6px; border: 1px solid #d1d4cd; border-radius: 10px;
|
||||
background: #ffffff; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto;
|
||||
}
|
||||
.dms-locale-opt {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit;
|
||||
font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none;
|
||||
color: #d4d4d4; cursor: pointer;
|
||||
color: #3b463d; cursor: pointer;
|
||||
}
|
||||
.dms-locale-opt:hover { background: #404040; color: #f5f5f5; }
|
||||
.dms-locale-opt.active { background: #404040; color: #f5f5f5; }
|
||||
.dms-locale-opt.active .dms-locale-name { color: #00DC82; }
|
||||
.dms-locale-en { margin-left: auto; font-size: 11px; color: #737373; }
|
||||
.dms-locale-opt:hover { background: #d1d4cd; color: #3b463d; }
|
||||
.dms-locale-opt.active { background: #d1d4cd; color: #3b463d; }
|
||||
.dms-locale-opt.active .dms-locale-name { color: #92b195; }
|
||||
.dms-locale-en { margin-left: auto; font-size: 11px; color: #6d766e; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@
|
|||
|
||||
<script>
|
||||
window.UPG_TOKEN = (new URLSearchParams(location.search).get('tk')||(typeof window!=='undefined'&&window.MAPBOX_TOKEN)||'');
|
||||
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
|
||||
// as ?locale=. ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
|
||||
// the leaf keeps its own document.documentElement.lang detection.
|
||||
window.DOXA_LOCALE = (new URLSearchParams(location.search).get('locale') || '');
|
||||
</script>
|
||||
|
||||
<div class="stage-head">
|
||||
|
|
@ -69,11 +73,11 @@
|
|||
slot.innerHTML = '';
|
||||
slot.classList.toggle('is-wall', profile === 'resources-list');
|
||||
var el = document.createElement('gospel-resources-map');
|
||||
el.setAttribute('profile-config', JSON.stringify({
|
||||
el.setAttribute('profile-config', JSON.stringify(Object.assign({
|
||||
profile: profile,
|
||||
tk: window.UPG_TOKEN || '',
|
||||
assetsBase: '/assets',
|
||||
}));
|
||||
}, window.DOXA_LOCALE ? { locale: window.DOXA_LOCALE } : {})));
|
||||
slot.appendChild(el);
|
||||
}
|
||||
document.querySelectorAll('.tab').forEach(function (b) {
|
||||
|
|
|
|||
|
|
@ -354,9 +354,20 @@ useShadowStyles(
|
|||
'.gm-sec{margin-top:12px}' +
|
||||
'.gm-sec-h{font-weight:800;font-size:12px;letter-spacing:.02em;margin-bottom:6px}' +
|
||||
'.gm-load{padding:10px 0;font-size:11.5px;opacity:.65}' +
|
||||
'.gm-pg{width:100%;text-align:left;display:block;padding:8px 9px;margin-bottom:6px;border-radius:8px;' +
|
||||
'border:1px solid rgba(0,0,0,.10);background:rgba(0,0,0,.02);color:inherit;cursor:pointer;font:inherit}' +
|
||||
// .gm-pg is now the SHELL only. The resource rows are a <div> shell (so the chips
|
||||
// inside can be real anchors) while the no-resource rows are still a <button>; both
|
||||
// must look identical, so the shell carries the box and .gm-pg-btn/.gm-pg-open carry
|
||||
// the interactive bits. cursor:pointer is NOT on the shell — on a resource row the
|
||||
// gaps between the name and the chips are not clickable, and saying otherwise lies.
|
||||
'.gm-pg{width:100%;box-sizing:border-box;text-align:left;display:block;padding:8px 9px;' +
|
||||
'margin-bottom:6px;border-radius:8px;' +
|
||||
'border:1px solid rgba(0,0,0,.10);background:rgba(0,0,0,.02);color:inherit;font:inherit}' +
|
||||
'.is-dark .gm-pg{border-color:rgba(255,255,255,.10);background:rgba(255,255,255,.03)}' +
|
||||
'.gm-pg-btn{cursor:pointer}' +
|
||||
// The drill-in control on a resource row: a bare button that inherits the shell's
|
||||
// look, so the row still reads as one tap-target for the name + language line.
|
||||
'.gm-pg-open{display:block;width:100%;text-align:left;padding:0;margin:0;border:0;' +
|
||||
'background:transparent;color:inherit;font:inherit;cursor:pointer}' +
|
||||
'.gm-pg-n{display:block;font-weight:700;font-size:12.5px}' +
|
||||
'.gm-pg-l{display:block;font-size:11px;opacity:.75;margin-top:1px}' +
|
||||
'.gm-code{display:inline-block;font:600 10px ui-monospace,SFMono-Regular,Menlo,monospace;' +
|
||||
|
|
@ -370,6 +381,10 @@ useShadowStyles(
|
|||
'.gm-chip.bad{background:rgba(180,35,24,.16);border-color:rgba(180,35,24,.38)}' +
|
||||
'.gm-chip.warn{background:rgba(224,122,43,.18);border-color:rgba(224,122,43,.38)}' +
|
||||
'.gm-chip.meh{background:rgba(120,130,140,.16);border-color:rgba(120,130,140,.30)}' +
|
||||
// Only an ANCHOR chip gets the pointer + underline. A <span> chip ("none found",
|
||||
// "unknown") must keep looking inert, because it is.
|
||||
'a.gm-chip{cursor:pointer}' +
|
||||
'a.gm-chip:hover,a.gm-chip:focus-visible{text-decoration:underline}' +
|
||||
'.gm-none{font-size:11px;opacity:.7}' +
|
||||
'.gm-back{border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
|
||||
'font:600 11px inherit;padding:5px 11px;border-radius:8px;cursor:pointer;min-height:30px}' +
|
||||
|
|
@ -469,7 +484,7 @@ useShadowStyles(
|
|||
<div class="gm-sec" v-if="sameLanguage.length">
|
||||
<div class="gm-sec-h">Also speak {{ selectedGroup.language }} ({{ sameLanguage.length }})</div>
|
||||
<button v-for="g in sameLanguage.slice(0, 12)" :key="g.people_group + g.countries"
|
||||
type="button" class="gm-pg" @click="openGroup(g)">
|
||||
type="button" class="gm-pg gm-pg-btn" @click="openGroup(g)">
|
||||
<span class="gm-pg-n">{{ g.people_group }}</span>
|
||||
<span class="gm-pg-l">{{ fmt(g.population) }} people · {{ g.countries }}</span>
|
||||
</button>
|
||||
|
|
@ -511,19 +526,34 @@ useShadowStyles(
|
|||
<div v-if="!withResources.length" class="gm-none">
|
||||
Nothing found for any group here in the pools checked.
|
||||
</div>
|
||||
<button v-for="g in withResources.slice(0, shown)" :key="'r' + g.people_group + g.language"
|
||||
type="button" class="gm-pg" @click="openGroup(g)">
|
||||
<span class="gm-pg-n">{{ g.people_group }}</span>
|
||||
<span class="gm-pg-l">{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code></span>
|
||||
<!-- NOT a <button> wrapper. The chips below are real <a href> links to the
|
||||
resource itself, and HTML forbids interactive content inside a button —
|
||||
a nested anchor is hoisted out by the parser and stops being a link.
|
||||
So the row is a plain container: the NAME is the drill-in control, the
|
||||
CHIPS are the outbound links, and the two no longer fight. -->
|
||||
<div v-for="g in withResources.slice(0, shown)" :key="'r' + g.people_group + g.language"
|
||||
class="gm-pg">
|
||||
<button type="button" class="gm-pg-open" @click="openGroup(g)">
|
||||
<span class="gm-pg-n">{{ g.people_group }}</span>
|
||||
<span class="gm-pg-l">{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code></span>
|
||||
</button>
|
||||
<span class="gm-chips">
|
||||
<span v-if="g.scripture_url" class="gm-chip" :class="vchip(g.scripture_url).cls">
|
||||
<!-- The URL the verification pass already proved. Surfacing it is the
|
||||
whole point: a chip that SAYS "link checked" while hiding the link
|
||||
costs middle-click, open-in-new-tab, copy-link-address and link
|
||||
semantics for screen readers. -->
|
||||
<a v-if="g.scripture_url" class="gm-chip" :class="vchip(g.scripture_url).cls"
|
||||
:href="g.scripture_url" target="_blank" rel="noopener noreferrer"
|
||||
:title="vtitle(g.scripture_url)">
|
||||
Scripture · {{ vchip(g.scripture_url).label }}
|
||||
</span>
|
||||
<span v-if="g.film_url" class="gm-chip" :class="vchip(g.film_url).cls">
|
||||
</a>
|
||||
<a v-if="g.film_url" class="gm-chip" :class="vchip(g.film_url).cls"
|
||||
:href="g.film_url" target="_blank" rel="noopener noreferrer"
|
||||
:title="vtitle(g.film_url)">
|
||||
Film · {{ vchip(g.film_url).label }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="withResources.length > shown" class="gm-more">
|
||||
Showing {{ shown }} of {{ withResources.length }}.
|
||||
<button type="button" class="gm-back" @click="shown = withResources.length">Show all</button>
|
||||
|
|
@ -532,8 +562,12 @@ useShadowStyles(
|
|||
|
||||
<div class="gm-sec" v-if="withoutResources.length">
|
||||
<div class="gm-sec-h">No resource found ({{ withoutResources.length }})</div>
|
||||
<!-- These groups have NO verified URL, so NOTHING here becomes an anchor.
|
||||
A dead <a> that navigates nowhere would claim a resource exists — the
|
||||
exact overclaim this map refuses everywhere else. The row stays a plain
|
||||
drill-in button and keeps saying, honestly, that nothing was found. -->
|
||||
<button v-for="g in withoutResources.slice(0, shown)" :key="'n' + g.people_group + g.language"
|
||||
type="button" class="gm-pg" @click="openGroup(g)">
|
||||
type="button" class="gm-pg gm-pg-btn" @click="openGroup(g)">
|
||||
<span class="gm-pg-n">{{ g.people_group }}</span>
|
||||
<span class="gm-pg-l">
|
||||
{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,12 @@
|
|||
// Mount one element. Profile is selectable via ?profile= (dev convenience) so
|
||||
// the composite MFE profile (upg-100-app) can be previewed too; defaults to the map.
|
||||
var _profile = (new URLSearchParams(location.search).get('profile') || 'upg-100-list');
|
||||
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
|
||||
// as ?locale=; ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
|
||||
// the leaf keeps its own document.documentElement.lang detection.
|
||||
var _locale = (new URLSearchParams(location.search).get('locale') || '');
|
||||
var config = { profile: _profile, tk: window.UPG_TOKEN || '' };
|
||||
if (_locale) config.locale = _locale;
|
||||
var el = document.createElement('my-upg-100-list-map');
|
||||
el.setAttribute('profile-config', JSON.stringify(config));
|
||||
document.getElementById('slot').appendChild(el);
|
||||
|
|
|
|||
|
|
@ -61,12 +61,37 @@ const view = ref('map')
|
|||
let column = null
|
||||
let offScroll = null
|
||||
|
||||
// PAGE-LEVEL config keys a pane INHERITS from the host page.
|
||||
//
|
||||
// A cap-4 composite nests its children as custom elements in THIS document, so there is no
|
||||
// inner leaf page to hand them the page-scoped values an iframe child would have received
|
||||
// from its own generated index.html. Anything the generator bakes ONCE PER PAGE — where
|
||||
// assets are served from, which backend to call, the theme — has to be forwarded explicitly
|
||||
// or the child silently falls back to its own default.
|
||||
//
|
||||
// BUG-UPG100MFE-ASSET-PATH-DEPTH-404S (2026-09-20): `assetsBase` was not forwarded. The
|
||||
// generator emitted the CORRECT value ("../../assets", identical to every sibling leaf), but
|
||||
// the poster-wall pane never saw it and fell back to its own './assets' default — which
|
||||
// resolves against the LEAF directory, so all 27 previews requested
|
||||
// upg-100-mfe/assets/previews/<slug>.webp and 404'd. The depth calculation was never wrong;
|
||||
// the value simply never reached the consumer.
|
||||
//
|
||||
// Forwarded by KEY LIST, not by spreading the whole host config: a pane's own `profile` /
|
||||
// `instanceId` must NOT be overwritten by the host's, which is what makes a pane a distinct
|
||||
// view at all. Anything a pane declares still wins (pane.config is spread last).
|
||||
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'locale', 'lang', 'isDark', 'qa']
|
||||
|
||||
function paneElement(pane) {
|
||||
const el = document.createElement(TAG)
|
||||
const pcfg = { ...spec.value.config, ...pane.config }
|
||||
const host = cfg.value || {}
|
||||
const inherited = {}
|
||||
for (const k of INHERITED_PAGE_KEYS) {
|
||||
if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
|
||||
}
|
||||
// `lang` is the alias the page may carry; children read `locale`.
|
||||
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
|
||||
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
|
||||
if (tk.value) pcfg.tk = tk.value
|
||||
const loc = cfg.value?.locale || cfg.value?.lang
|
||||
if (loc) pcfg.locale = loc
|
||||
el.setAttribute('profile-config', JSON.stringify(pcfg))
|
||||
return el
|
||||
}
|
||||
|
|
@ -114,19 +139,25 @@ const SHARE_EMBED_PATH = 'my-upg-100-list/upg-100-mfe/index.html'
|
|||
// Child components' <style> blocks are dropped by defineCustomElement, so ALL CSS
|
||||
// goes through useShadowStyles: the layout's grid styles + this element's chrome.
|
||||
useShadowStyles(PANE_GRID_STYLES + `
|
||||
/* ── palette: app-wrapper-material's --ui-* (light default, dark via system) ── */
|
||||
/* SELF-SIZE (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). Every layer inside this element is
|
||||
position:absolute / height:100% (.upgmfe -> .pane-col -> .pane), so the whole chain resolves to
|
||||
ZERO unless the HOST itself is sized. The standalone leaf page papered over it with a page-level
|
||||
rule, but inside a tab layout no such rule exists -> the header rendered and every pane collapsed
|
||||
to 0, which is the "elements are not embedding" report. Declaring :host here makes the element
|
||||
fill whatever slot it is mounted into, in every deploy method. */
|
||||
:host{ display:block; width:100%; height:100%; min-height:420px; }
|
||||
/* ── palette: DX-life material light ── */
|
||||
.upgmfe{
|
||||
--ui-bg:#ffffff; --ui-bg-elevated:#f9f9f9; --ui-bg-accented:#f0f0f0;
|
||||
--ui-border:#e5e5e5; --ui-text:#0f0f0f; --ui-text-muted:#606060; --ui-primary:#1a73e8;
|
||||
--ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;
|
||||
--ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;
|
||||
--header:56px;
|
||||
position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;
|
||||
background:var(--ui-bg); color:var(--ui-text);
|
||||
font:14px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
.upgmfe{ --ui-bg:#0f0f0f; --ui-bg-elevated:#181818; --ui-bg-accented:#272727;
|
||||
--ui-border:#303030; --ui-text:#f1f1f1; --ui-text-muted:#aaa; --ui-primary:#64b5f6; }
|
||||
}
|
||||
/* NO AUTO-DARK. The DX design pattern is material LIGHT (Driver 2026-09-20): a system set to dark
|
||||
mode must NOT flip this element. The prefers-color-scheme:dark override that used to live here is
|
||||
removed deliberately — the light palette above is the only theme. */
|
||||
/* ── header: same height/border as the shell; switcher centred; share at right ── */
|
||||
.upgmfe .hdr{
|
||||
flex:0 0 var(--header); height:var(--header); position:relative;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* dropdowns + faceted (cross-filter) counts are copied from UupgsList.vue — NOT imported
|
||||
* from DX-life. DX-life is READ-ONLY source; nothing there is edited.
|
||||
*
|
||||
* THE CAT-2 CONTRACT (mirrors plugin-designs/big-picture/src/components/BpGroupsTable.vue)
|
||||
* THE CAT-2 CONTRACT (this IS the real cap-2 grid the big-picture Cat-4 composite nests)
|
||||
* - By DEFAULT it owns its internal search box + filter dropdowns and filters itself.
|
||||
* - `show-search` / `show-filters` ([Boolean,String], default true) TURN OFF its own
|
||||
* controls so an external header (e.g. the Big-Picture Cat-4 shell) can drive it.
|
||||
|
|
@ -417,7 +417,7 @@ onMounted(getUUPGs)
|
|||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
:label="t.wagf_block || 'Block'" name="wagf_block"
|
||||
:label="t.wagf_block || 'Bloc'" name="wagf_block"
|
||||
:options="filterOptions.wagf_block || []"
|
||||
:value="activeFilters.wagf_block?.value ?? ''"
|
||||
:placeholder="t.type_to_search || 'Type to search...'"
|
||||
|
|
@ -510,6 +510,11 @@ onMounted(getUUPGs)
|
|||
(blocks/_filters.scss + blocks/_cards.scss); those don't ship here, so the essential
|
||||
layout/look is inlined and scoped to this element's shadow root. Class names are
|
||||
preserved so the DX-life SCSS COULD still apply if this ever mounts inside that app. */
|
||||
/* Self-size as a block regardless of how a parent mounts us — never depend on the mounter
|
||||
forcing display (an unstyled host defaults to inline and mis-sizes). This grid is
|
||||
natural-height (grows with content), so :host is display:block, width:100%, no fixed height.
|
||||
See BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN-DISPLAY-BLOCK. */
|
||||
:host { display: block; width: 100%; }
|
||||
.pgg-root {
|
||||
--brand: #2563eb; --ink: #0f172a; --mut: #64748b; --line: #e2e8f0; --card: #fff; --bg: #f8fafc;
|
||||
width: 100%; box-sizing: border-box; color: var(--ink); background: var(--bg);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
<VerticalTabsLayout
|
||||
:title="title"
|
||||
nav-label="Plugin elements · by capability 5 → 1"
|
||||
:header="true"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
@update:active="select">
|
||||
|
|
@ -50,7 +51,7 @@
|
|||
Self-drawn SVG, green accent to match the admin shell; no external asset (offline safe). -->
|
||||
<template #brand>
|
||||
<span class="ps-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#00DC82" stroke-width="2"
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#92b195" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 3v3M14 3v3M7 6h10v5a5 5 0 0 1-10 0V6Z" />
|
||||
<path d="M12 16v5" />
|
||||
|
|
@ -117,14 +118,25 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<iframe
|
||||
v-else
|
||||
class="ps-frame"
|
||||
:src="row.loaded ? row.src : 'about:blank'"
|
||||
:title="row.label"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
<template v-else>
|
||||
<!-- iframe panes: a broken leaf page (404 / script error inside the frame) used to show a
|
||||
silent blank frame. @load flips a per-row flag; a short watchdog after the src is set
|
||||
marks the pane errored if it never fired — so ONE bad nested plugin shows an error
|
||||
overlay in ITS pane, never a blank studio. The @error alone is unreliable for cross-
|
||||
document frames, hence the watchdog. -->
|
||||
<iframe
|
||||
class="ps-frame"
|
||||
:src="row.loaded ? row.src : 'about:blank'"
|
||||
:title="row.label"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
@load="onFrameLoad(row)"
|
||||
@error="onFrameError(row)"></iframe>
|
||||
<div v-if="row.state === 'error'" class="ps-hint err">
|
||||
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
|
||||
</div>
|
||||
|
|
@ -143,36 +155,36 @@ import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/la
|
|||
const PS_SHADOW_CSS = `
|
||||
.ps-logo { display:inline-flex; align-items:center; }
|
||||
.ps-logo svg { display:block; }
|
||||
.ps-count { color:#a3a3a3; }
|
||||
.ps-count { color:#6d766e; }
|
||||
.ps-locale { position:relative; display:inline-flex; }
|
||||
.ps-locale-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:4px 9px; border:1px solid #404040; border-radius:8px; background:#262626; color:#f5f5f5; cursor:pointer; }
|
||||
.ps-locale-btn:hover { background:#404040; }
|
||||
.ps-locale-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:4px 9px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; }
|
||||
.ps-locale-btn:hover { background:#d1d4cd; }
|
||||
.ps-flag { font-size:13px; line-height:1; }
|
||||
.ps-locale-name { max-width:11ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ps-dots { color:#a3a3a3; }
|
||||
.ps-locale-menu { position:absolute; top:100%; right:0; margin-top:4px; z-index:30; min-width:180px; list-style:none; padding:6px; border:1px solid #404040; border-radius:10px; background:#1f1f1f; box-shadow:0 12px 32px rgba(0,0,0,.5); max-height:320px; overflow:auto; }
|
||||
.ps-locale-opt { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:7px 9px; border:0; border-radius:7px; background:none; color:#d4d4d4; cursor:pointer; }
|
||||
.ps-locale-opt:hover { background:#404040; color:#f5f5f5; }
|
||||
.ps-locale-opt.active { background:#404040; color:#f5f5f5; }
|
||||
.ps-locale-opt.active .ps-locale-name { color:#00DC82; }
|
||||
.ps-locale-en { margin-left:auto; font-size:11px; color:#737373; }
|
||||
.ps-dots { color:#6d766e; }
|
||||
.ps-locale-menu { position:absolute; top:100%; right:0; margin-top:4px; z-index:30; min-width:180px; list-style:none; padding:6px; border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; box-shadow:0 12px 32px rgba(0,0,0,.5); max-height:320px; overflow:auto; }
|
||||
.ps-locale-opt { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:7px 9px; border:0; border-radius:7px; background:none; color:#3b463d; cursor:pointer; }
|
||||
.ps-locale-opt:hover { background:#d1d4cd; color:#3b463d; }
|
||||
.ps-locale-opt.active { background:#d1d4cd; color:#3b463d; }
|
||||
.ps-locale-opt.active .ps-locale-name { color:#92b195; }
|
||||
.ps-locale-en { margin-left:auto; font-size:11px; color:#6d766e; }
|
||||
.ps-toolbar { display:flex; flex-direction:column; gap:8px; padding:10px 12px 0; }
|
||||
.ps-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.ps-bar-t { flex:1 1 auto; min-width:0; }
|
||||
.ps-bar-t b { font-size:15px; color:#f5f5f5; }
|
||||
.ps-bar-sub { display:block; font-size:12px; color:#a3a3a3; }
|
||||
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #404040; border-radius:8px; background:#262626; color:#f5f5f5; cursor:pointer; white-space:nowrap; }
|
||||
.ps-btn:hover { background:#404040; }
|
||||
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #262626; border-radius:8px; background:#171717; }
|
||||
.ps-embed label { font-size:12px; color:#a3a3a3; display:flex; justify-content:space-between; align-items:center; }
|
||||
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #404040; border-radius:6px; background:#0d0d0d; color:#f5f5f5; padding:6px; resize:vertical; }
|
||||
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #262626; border-radius:10px; overflow:hidden; background:#171717; }
|
||||
.ps-bar-t b { font-size:15px; color:#3b463d; }
|
||||
.ps-bar-sub { display:block; font-size:12px; color:#6d766e; }
|
||||
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; white-space:nowrap; }
|
||||
.ps-btn:hover { background:#d1d4cd; }
|
||||
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; }
|
||||
.ps-embed label { font-size:12px; color:#6d766e; display:flex; justify-content:space-between; align-items:center; }
|
||||
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #d1d4cd; border-radius:6px; background:#ffffff; color:#3b463d; padding:6px; resize:vertical; }
|
||||
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#f3f3f1; }
|
||||
.ps-pane { position:absolute; inset:0; }
|
||||
.ps-slot { position:absolute; inset:0; }
|
||||
.ps-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#171717; }
|
||||
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#a3a3a3; font-size:13px; padding:20px; }
|
||||
.ps-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#f3f3f1; }
|
||||
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#6d766e; font-size:13px; padding:20px; }
|
||||
.ps-hint.err { color:#f87171; }
|
||||
.ps-hint small { color:#737373; }
|
||||
.ps-hint small { color:#6d766e; }
|
||||
`
|
||||
|
||||
// Reader-facing capability names, most-composed first (5 → 1). Mirrors generate-gallery's
|
||||
|
|
@ -301,12 +313,21 @@ export default defineComponent({
|
|||
this.localeOpen = false
|
||||
saveLocale(code)
|
||||
try {
|
||||
const slot = this._slots && this._slots[this.activeKey]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute && this.active) {
|
||||
const pc = { profile: this.active.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
const row = this.active
|
||||
if (row && row.mode === 'iframe') {
|
||||
// iframe panes carry locale as a URL param; re-point the src so the nested SPA re-mounts in
|
||||
// the new language. Re-arm the load watchdog (via loaded=false → select) so a broken
|
||||
// re-load still shows this pane's error overlay, not a blank frame.
|
||||
row.loaded = false
|
||||
this.select(this.activeKey)
|
||||
} else {
|
||||
const slot = this._slots && this._slots[this.activeKey]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute && row) {
|
||||
const pc = { profile: row.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
|
||||
},
|
||||
|
|
@ -371,7 +392,17 @@ export default defineComponent({
|
|||
const row = this.rows.find(r => r.key === key)
|
||||
if (!row) return
|
||||
if (row.mode === 'iframe') {
|
||||
if (!row.loaded) { row.src = this.withToken(this.resolve(row.leaf)); row.loaded = true }
|
||||
if (!row.loaded) {
|
||||
row.src = this.withToken(this.resolve(row.leaf))
|
||||
row.loaded = true
|
||||
row.state = 'loading'
|
||||
// Watchdog: if no load event arrives in 20s, show this pane's error overlay rather than a
|
||||
// silent blank frame. onFrameLoad clears it (and un-errors a late load).
|
||||
if (row._frameTimer) clearTimeout(row._frameTimer)
|
||||
row._frameTimer = setTimeout(() => {
|
||||
if (row.state !== 'ready') { row.state = 'error'; row.error = 'nested plugin did not load in time' }
|
||||
}, 20000)
|
||||
}
|
||||
} else if (!row.mounted) {
|
||||
this.mountElement(row)
|
||||
}
|
||||
|
|
@ -382,6 +413,10 @@ export default defineComponent({
|
|||
const q = []
|
||||
if (tk) q.push('tk=' + encodeURIComponent(tk))
|
||||
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
|
||||
// Push the studio's current preview language down to the nested SPA as a URL param — the
|
||||
// leaf pages read `?locale=` and inject it into their element's profile-config.locale. The
|
||||
// studio is the "simulate DX-life" surface, so it always carries a concrete locale.
|
||||
if (this.locale) q.push('locale=' + encodeURIComponent(this.locale))
|
||||
if (!q.length) return url
|
||||
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
|
||||
},
|
||||
|
|
@ -390,21 +425,49 @@ export default defineComponent({
|
|||
row.state = 'loading'
|
||||
const jsUrl = this.resolve(row.bundleJs)
|
||||
loadScriptOnce(jsUrl).then(() => {
|
||||
const slot = this._slots && this._slots[row.key]
|
||||
if (!slot) { row.state = 'error'; row.error = 'no slot'; return }
|
||||
slot.innerHTML = ''
|
||||
const el = document.createElement(row.tag)
|
||||
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-') }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
||||
slot.appendChild(el)
|
||||
row.state = 'ready'
|
||||
// Everything below is per-nested-plugin machinery; a throw here (bad tag name, a nested
|
||||
// element that blows up in its connectedCallback, a serialise failure) must NOT crash the
|
||||
// studio — it lands in THIS row's error pane. Hence the try/catch around the DOM mount, on
|
||||
// top of the .catch() that already handles a failed SCRIPT load.
|
||||
try {
|
||||
const slot = this._slots && this._slots[row.key]
|
||||
if (!slot) { row.state = 'error'; row.error = 'no slot'; return }
|
||||
slot.innerHTML = ''
|
||||
const el = document.createElement(row.tag)
|
||||
// Include the current preview locale so a pane mounted before any language switch already
|
||||
// renders in the studio's active language (ProfileLoader reads profile-config.locale).
|
||||
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale: this.locale }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
||||
slot.appendChild(el)
|
||||
row.state = 'ready'
|
||||
} catch (e) {
|
||||
row.state = 'error'
|
||||
row.error = 'mount failed: ' + ((e && e.message) || String(e))
|
||||
}
|
||||
}).catch((e) => {
|
||||
row.state = 'error'
|
||||
row.error = (e && e.message) || String(e)
|
||||
})
|
||||
},
|
||||
// iframe panes: a leaf that 404s or errors inside its own document would otherwise show a
|
||||
// silent blank frame. onload fires for BOTH a good load and an error document, so we can't tell
|
||||
// them apart cross-origin — instead we arm a watchdog when the src is set (in select) and treat
|
||||
// a load that arrives as success (clear the watchdog + any error). onFrameError catches the rare
|
||||
// same-origin error event. Either way ONE bad nested plugin is contained to its own pane.
|
||||
onFrameLoad(row) {
|
||||
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
|
||||
// A load event means the frame document resolved; leave any deeper in-frame failure to the
|
||||
// leaf's own error UI. Only clear a watchdog-set error if the frame genuinely loaded late.
|
||||
if (row.state === 'error' && row.error === 'nested plugin did not load in time') { row.state = 'ready'; row.error = '' }
|
||||
else if (row.state !== 'error') row.state = 'ready'
|
||||
},
|
||||
onFrameError(row) {
|
||||
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
|
||||
row.state = 'error'
|
||||
row.error = 'the nested plugin failed to load'
|
||||
},
|
||||
copyText(text, btn) {
|
||||
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
|
||||
|
|
@ -419,37 +482,37 @@ export default defineComponent({
|
|||
<style>
|
||||
/* The shell (top bar + rail + body) is the shared VerticalTabsLayout. Only the studio's own
|
||||
content-area styles remain here; mirror of PS_SHADOW_CSS above. Keep the two in sync.
|
||||
Reskinned to the dx-life admin tokens (neutral surfaces, green #00DC82 accent) to match the shell. */
|
||||
Reskinned to the dx-life admin tokens (neutral surfaces, green #92b195 accent) to match the shell. */
|
||||
.ps-logo { display: inline-flex; align-items: center; }
|
||||
.ps-logo svg { display: block; }
|
||||
.ps-count { color: #a3a3a3; }
|
||||
.ps-count { color: #6d766e; }
|
||||
.ps-locale { position: relative; display: inline-flex; }
|
||||
.ps-locale-btn { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 4px 9px; border: 1px solid #404040; border-radius: 8px; background: #262626; color: #f5f5f5; cursor: pointer; }
|
||||
.ps-locale-btn:hover { background: #404040; }
|
||||
.ps-locale-btn { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 4px 9px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; }
|
||||
.ps-locale-btn:hover { background: #d1d4cd; }
|
||||
.ps-flag { font-size: 13px; line-height: 1; }
|
||||
.ps-locale-name { max-width: 11ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ps-dots { color: #a3a3a3; }
|
||||
.ps-locale-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 30; min-width: 180px; list-style: none; padding: 6px; border: 1px solid #404040; border-radius: 10px; background: #1f1f1f; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto; }
|
||||
.ps-locale-opt { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none; color: #d4d4d4; cursor: pointer; }
|
||||
.ps-locale-opt:hover { background: #404040; color: #f5f5f5; }
|
||||
.ps-locale-opt.active { background: #404040; color: #f5f5f5; }
|
||||
.ps-locale-opt.active .ps-locale-name { color: #00DC82; }
|
||||
.ps-locale-en { margin-left: auto; font-size: 11px; color: #737373; }
|
||||
.ps-dots { color: #6d766e; }
|
||||
.ps-locale-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 30; min-width: 180px; list-style: none; padding: 6px; border: 1px solid #d1d4cd; border-radius: 10px; background: #ffffff; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto; }
|
||||
.ps-locale-opt { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none; color: #3b463d; cursor: pointer; }
|
||||
.ps-locale-opt:hover { background: #d1d4cd; color: #3b463d; }
|
||||
.ps-locale-opt.active { background: #d1d4cd; color: #3b463d; }
|
||||
.ps-locale-opt.active .ps-locale-name { color: #92b195; }
|
||||
.ps-locale-en { margin-left: auto; font-size: 11px; color: #6d766e; }
|
||||
.ps-toolbar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px 0; }
|
||||
.ps-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ps-bar-t { flex: 1 1 auto; min-width: 0; }
|
||||
.ps-bar-t b { font-size: 15px; color: #f5f5f5; }
|
||||
.ps-bar-sub { display: block; font-size: 12px; color: #a3a3a3; }
|
||||
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #404040; border-radius: 8px; background: #262626; color: #f5f5f5; cursor: pointer; white-space: nowrap; }
|
||||
.ps-btn:hover { background: #404040; }
|
||||
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #262626; border-radius: 8px; background: #171717; }
|
||||
.ps-embed label { font-size: 12px; color: #a3a3a3; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #404040; border-radius: 6px; background: #0d0d0d; color: #f5f5f5; padding: 6px; resize: vertical; }
|
||||
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #262626; border-radius: 10px; overflow: hidden; background: #171717; }
|
||||
.ps-bar-t b { font-size: 15px; color: #3b463d; }
|
||||
.ps-bar-sub { display: block; font-size: 12px; color: #6d766e; }
|
||||
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; white-space: nowrap; }
|
||||
.ps-btn:hover { background: #d1d4cd; }
|
||||
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #d1d4cd; border-radius: 8px; background: #f3f3f1; }
|
||||
.ps-embed label { font-size: 12px; color: #6d766e; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #d1d4cd; border-radius: 6px; background: #ffffff; color: #3b463d; padding: 6px; resize: vertical; }
|
||||
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #d1d4cd; border-radius: 10px; overflow: hidden; background: #f3f3f1; }
|
||||
.ps-pane { position: absolute; inset: 0; }
|
||||
.ps-slot { position: absolute; inset: 0; }
|
||||
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #171717; }
|
||||
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #a3a3a3; font-size: 13px; padding: 20px; }
|
||||
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #f3f3f1; }
|
||||
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #6d766e; font-size: 13px; padding: 20px; }
|
||||
.ps-hint.err { color: #f87171; }
|
||||
.ps-hint small { color: #737373; }
|
||||
.ps-hint small { color: #6d766e; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
* migrated bundle behaves identically.
|
||||
*/
|
||||
import { lbl } from './src/composables/useDashboardAggregations.js'
|
||||
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
|
||||
import { canonicalFamilyName } from '@map/colors/language-family.js'
|
||||
import langFamilyByIso from '@map/data/langFamilyByIso.json'
|
||||
|
||||
// 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 }
|
||||
|
|
@ -24,7 +27,11 @@ const code = (v) => (v && typeof v === 'object' ? (v.value ?? v.label ?? '') : (
|
|||
export default {
|
||||
// ── identity ──
|
||||
id: 'doxa-research-dashboard',
|
||||
title: 'Research Dashboard',
|
||||
// "Big Picture Research Dashboard" (Driver 2026-09-20). The share button embeds THIS whole
|
||||
// element on a partner site, so the title is what a visitor reads there — it has to name the
|
||||
// product, not just the page. "Big Picture" is also the cap-4 composite's name and tab-1 of the
|
||||
// cap-5 SPA, so one term now runs consistently from the tab through to an embedded partner view.
|
||||
title: 'Big Picture Research Dashboard',
|
||||
element: 'doxa-research-dashboard',
|
||||
|
||||
// ── data source ── (consumed by useDashboardData; the arg is currently advisory —
|
||||
|
|
@ -37,29 +44,104 @@ export default {
|
|||
// ── facet accessors ── THE single former-duplicated FACET_ACC, declared ONCE.
|
||||
facets: {
|
||||
religion: { label: 'Religion', get: (r) => lbl(r.religion) },
|
||||
// ── Religion TIER 1 (card BUILD-STACKED-TREE-LEGEND) ──
|
||||
// The Doxa API's religion code is 1-3 chars whose FIRST LETTER is the family
|
||||
// ('MSN' → 'M' → Islam), which is precisely how the cap-3 map paints religion pins
|
||||
// (library/colors/religion.js:104 slices char 0). Naming the family here turns Religion
|
||||
// from a flat list of ~25 codes into the 2-tier tree the other three axes already are.
|
||||
//
|
||||
// Returns the human family NAME, not the letter: the value an accessor returns is BOTH the
|
||||
// display label and the bus filter value, and codes must never reach a user-facing menu
|
||||
// (same rule the People axis follows). A separate facet key — not a second accessor on
|
||||
// `religion` — because the bus matches on the accessor's exact output, so one key = one
|
||||
// projection keeps the byte-match contract intact.
|
||||
// Measured live (2,107 rows, 2026-09-20): 10 families, all named in RELIGION_FAMILIES,
|
||||
// family totals sum exactly to the leaf-code totals.
|
||||
religion_family: {
|
||||
label: 'Religion Family',
|
||||
get: (r) => {
|
||||
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
|
||||
const c = String(code || '').charAt(0).toUpperCase()
|
||||
return c && RELIGION_FAMILIES[c] ? RELIGION_FAMILIES[c] : ''
|
||||
},
|
||||
// The family LETTER, used only to look up the map's religion palette. Never displayed,
|
||||
// never sent to the bus.
|
||||
famKey: (r) => {
|
||||
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
|
||||
const c = String(code || '').charAt(0).toUpperCase()
|
||||
return RELIGION_FAMILIES[c] ? c : ''
|
||||
},
|
||||
},
|
||||
wagf_region: { label: 'Region', get: (r) => lbl(r.wagf_region) },
|
||||
wagf_block: { label: 'Block', get: (r) => lbl(r.wagf_block) },
|
||||
// "Bloc" (NOT "Block") — a bloc is a SUBSET of a region, per the WAGF place hierarchy
|
||||
// region > bloc > country. Display label only; the facet KEY stays `wagf_block` because the
|
||||
// API field, the fieldMappings, and every consumer prop (wagfBlock) are spelled that way.
|
||||
wagf_block: { label: 'Bloc', 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) },
|
||||
// ── Language TIER 1 — API value FIRST, ISO lookup as FALLBACK ──
|
||||
// MEASURED on the live API (2,107 rows, 2026-09-20): `imb_language_family` is null on
|
||||
// 2,073 of them — it is populated for just 34 rows (1.6%). Read alone, the Language tree
|
||||
// (one of the Driver's four axes) renders as a near-empty stub and looks broken.
|
||||
//
|
||||
// `primary_language` IS populated on 100% of rows and its `.value` is an ISO-639-3 code
|
||||
// ('abf'), and the library already ships @map/data/langFamilyByIso.json (815 entries).
|
||||
// Resolving through it lifts coverage from 34 → 2,031 rows (1.6% → 96.4%).
|
||||
//
|
||||
// The API's own value stays AUTHORITATIVE and is only fallen back on when absent — verified
|
||||
// safe: across every row where BOTH exist, the two agree 8/8 with 0 disagreements, so the
|
||||
// fallback never contradicts upstream, it only fills gaps.
|
||||
//
|
||||
// canonicalFamilyName() case-folds onto the palette's spelling (language-family.js:150) so
|
||||
// the value this returns is both the display label AND a direct palette key — one projection,
|
||||
// byte-match contract intact.
|
||||
language_family: {
|
||||
label: 'Language Family',
|
||||
get: (r) => {
|
||||
const api = lbl(r.imb_language_family)
|
||||
if (api) return canonicalFamilyName(api)
|
||||
const iso = r.primary_language && typeof r.primary_language === 'object' ? r.primary_language.value : null
|
||||
const hit = iso && langFamilyByIso[iso]
|
||||
return hit ? canonicalFamilyName(hit) : ''
|
||||
},
|
||||
},
|
||||
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) },
|
||||
// ── the PEOPLE axis for the research filter DROPDOWNS (card BUILD-FILTER-PEOPLE-AXIS-MISSING) ──
|
||||
// DX-life's research page filters people on `rop1` (UupgsList.vue: name="rop1",
|
||||
// label "People Group"), showing rop1.LABEL in the menu. The facets above key People by
|
||||
// CODE so the rail can cross-reference the map's semantic tree — codes must never reach a
|
||||
// user-facing menu. This is the LABEL projection of the SAME rop1 field: a separate facet
|
||||
// key (not a second accessor on affinity_bloc) because the selection bus matches on the
|
||||
// accessor's exact output, so one key = one projection keeps the byte-match contract intact.
|
||||
rop1: { label: 'People', get: (r) => lbl(r.rop1) },
|
||||
},
|
||||
|
||||
// ── 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).
|
||||
// ── THE FOUR STACKED TREES (card BUILD-STACKED-TREE-LEGEND) ──
|
||||
// Driver: "if you had a team of people trying to fill the globe with the gospel… they need to be
|
||||
// able to search by people, place, language, religion, and find who has the gospel resources and
|
||||
// who doesn't." The legend is how a researcher navigates those FOUR axes, in THIS order.
|
||||
//
|
||||
// `axis` names which cap-3 colour strategy paints the tree (see useTreeLegendColors.js) — the
|
||||
// legend reads the map's palettes, so the two can never drift. Status is NOT one of the four
|
||||
// axes; it stays below them as a flat utility list (`axis:null` = uncoloured), so the four
|
||||
// trees read as the stack the Driver asked for rather than as five peers.
|
||||
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 },
|
||||
{ id: 'people', title: 'People', axis: 'people', drill: ['affinity_bloc', 'cluster', 'people_group'], tree: 'affinity', topN: [30, 40, 60] },
|
||||
{ id: 'place', title: 'Place', axis: 'place', drill: ['wagf_region', 'wagf_block', 'country_code'] },
|
||||
{ id: 'language', title: 'Language', axis: 'language', drill: ['language_family', 'primary_language'] },
|
||||
// Religion was the odd flat axis; it is now the 2-tier tree the data always supported
|
||||
// (family → religion). See the `religion_family` facet above.
|
||||
{ id: 'religion', title: 'Religion', axis: 'religion', drill: ['religion_family', 'religion'], topN: [12, 24] },
|
||||
{ id: 'status', title: 'Status', axis: null, facet: 'engagement_status', topN: 16 },
|
||||
],
|
||||
|
||||
// ── KPI strip ── (computed by useDashboardShell; mirrors the original 6 stat computeds)
|
||||
|
|
@ -98,6 +180,27 @@ export default {
|
|||
fields: ['religion', 'primary_language', 'people_praying', 'adopted_by_churches', 'engagement_status'],
|
||||
},
|
||||
|
||||
// ── research filters (DX-life clone) ── header-1 search bar + header-2 expand-filters
|
||||
// dropdown SET, baked via DashboardResearchFilters → the shared @map SearchFilterHeader.
|
||||
// `dropdowns` = the facet keys rendered as dropdowns (each returns a LABEL the bus matches on,
|
||||
// so a pick byte-matches filteredRows); `searchKeys` = the facet keys the free-text search
|
||||
// matches across (mirrors DX-life's Fuse keys: name/place/region/language/religion — here the
|
||||
// name comes from the `columns` string set and these facet labels). One horizontal row,
|
||||
// horizontal-scroll on overflow (card BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW).
|
||||
// ── AXIS ORDER: People · Place · Language · Religion ──
|
||||
// Place expands to its three tiers region > bloc > country, so the row reads
|
||||
// [People] [Region] [Bloc] [Country] [Language] [Religion].
|
||||
// People (`rop1`) was MISSING entirely (card BUILD-FILTER-PEOPLE-AXIS-MISSING) — the data was
|
||||
// always there (it is a defaultField on the pray-tools source and DX-life filtered on it), only
|
||||
// the dropdown was dropped when this list was first written.
|
||||
researchFilters: {
|
||||
dropdowns: ['rop1', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
|
||||
searchPlaceholder: 'Search people groups, place, language, religion…',
|
||||
},
|
||||
// `rop1` included so free-text search matches the People axis too (DX-life's Fuse key set
|
||||
// includes 'rop1.label' — UupgsList.vue:299).
|
||||
searchKeys: ['name', 'rop1', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion', '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
|
||||
|
|
|
|||
|
|
@ -23,10 +23,33 @@ import { defineCustomElement } from 'vue'
|
|||
import { createPinia } from 'pinia'
|
||||
import ProfileLoader from '@map/ProfileLoader.vue'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { registerStrategies } from '@map/colors/_registry.js'
|
||||
import affinityBlock from '../doxa-research-map/src/colors/affinity-block.js'
|
||||
import doxaRegion from '../doxa-research-map/src/colors/doxa-region.js'
|
||||
|
||||
// ─── Bundle-private profile registry ─────────────────────────────────────────
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
||||
// ─── Bundle-private color strategies (card BUILD-STACKED-TREE-LEGEND) ────────
|
||||
// The stacked tree legend colours its People and Place trees from the cap-3 research map's
|
||||
// palettes, so the legend can never drift from the map's pins. Those two strategies are
|
||||
// PROFILE-LOCAL to doxa-research-map (registered there by its own src/colors glob — see
|
||||
// plugin-designs/doxa-research-map/index.js:38), so the dashboard bundle's registry does not
|
||||
// contain them unless we register them here too.
|
||||
//
|
||||
// This MATTERS because the failure is silent: getColorStrategy() falls back to language-family
|
||||
// for an unregistered mode, and useAffinityBlockLegendData.js:57 guards on propertyKey and
|
||||
// returns {} — so without this, every People swatch would quietly be neutral grey and nothing
|
||||
// would error. Registering the SAME modules means one palette, not a copied one.
|
||||
//
|
||||
// Imported EXPLICITLY rather than via import.meta.glob: a glob is resolved relative to this
|
||||
// file by Vite and a cross-bundle relative glob that stops matching fails silently (zero
|
||||
// strategies registered, grey swatches), whereas a named import fails loudly at build time.
|
||||
registerStrategies({
|
||||
'./affinity-block.js': { default: affinityBlock },
|
||||
'./doxa-region.js': { default: doxaRegion },
|
||||
})
|
||||
|
||||
// ─── Mapbox RTL text plugin — load ONCE before any map instance ──────────────
|
||||
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,16 @@
|
|||
* 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.
|
||||
* RESEARCH-FILTERS (DX-life clone): DashboardResearchFilters sits between the topbar and the
|
||||
* KPI strip as header-1 (search bar) + header-2 (the expand-filters dropdown SET — ONE row,
|
||||
* horizontal-scroll on overflow). It is a THIN ADAPTER over the SHARED, parameterized
|
||||
* @map/components/search/SearchFilterHeader.vue (cloned from the DX-life research page's
|
||||
* UupgsList filter block), coupling it to the selection bus: the search bar → bus.setSearch,
|
||||
* a dropdown pick → bus.setFilter(facetKey, label). Every linked panel (table/map/charts/
|
||||
* facet-rail/KPIs) honors bus.searchTerm via the shared makeSearchMatcher, so searching AND
|
||||
* filtering re-derive the whole surface with no panel edits. config.researchFilters +
|
||||
* config.searchKeys drive which axes show + what the text search matches. The geocoder-based
|
||||
* DashboardSearchHeader (DOXA-LIB-3) remains available in the kit but is not mounted here.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -23,7 +28,7 @@ import { useDashboardShell, fmt } from './src/composables/useDashboardShell.js'
|
|||
import { useDashboardLayout } from './src/composables/useDashboardLayout.js'
|
||||
import {
|
||||
DashboardTopBar,
|
||||
DashboardSearchHeader,
|
||||
DashboardResearchFilters,
|
||||
DashboardFacetRail,
|
||||
DashboardMapPanel,
|
||||
DashboardDataTable,
|
||||
|
|
@ -64,13 +69,13 @@ function kpiText(k) {
|
|||
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() }
|
||||
// sticky export bar — only when a selection exists. Uses the ALL-FIELDS async path, same as the
|
||||
// table header's Export CSV button, so both doors emit the identical full-width record.
|
||||
// `selectionVersion` is the reactive handle on the (deliberately non-reactive) selectionSet —
|
||||
// see the performance contract in useSelectionBus.js. Touch it or this never updates.
|
||||
const selCount = computed(() => { bus.selectionVersion?.value; return bus.selectionSet ? bus.selectionSet.size : 0 })
|
||||
function exportSelection() { exporter.exportCsvAsync() }
|
||||
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) {
|
||||
|
|
@ -82,10 +87,30 @@ function onBarClick(e) {
|
|||
// The grid spine — injected into the shadow root (scoped <style> is dropped in the
|
||||
// defineCustomElement build). Verbatim from the original research-dashboard.
|
||||
useShadowStyles(`
|
||||
/* ── DX-LIFE PALETTE TOKENS (card BUILD-DASHBOARD-PALETTE-DRIFT-TOKENIZE) ──
|
||||
THE ONE PLACE the dashboard's colours are decided. Every panel must resolve its colour
|
||||
through a token below — no raw hex in a component. The drift this fixes: 69 blue-grey
|
||||
literals (line/muted/surface/text blue-greys) had accumulated across 8 standalone-
|
||||
authored panels, so the dashboard rendered a COOLER palette than the light shell hosting
|
||||
it. Nobody chose that; each panel simply invented its own hex.
|
||||
|
||||
Values are the live DX-life site's material-light palette (see
|
||||
_qa-cycle/01-ideation-research/REFERENCE-slim-material-ui-standards.md, REF-slim-material-ui-standards).
|
||||
LIGHT ONLY — there is no dark mode in this product and none may be added.
|
||||
|
||||
--dx-scroll-thumb is a DELIBERATE neutral, not a sage: it must read between line and muted
|
||||
on both white and surface, which a sage tint does not do. It is named, therefore chosen. */
|
||||
:host{ display:block; width:100%; height:100%; min-height:560px; box-sizing:border-box;
|
||||
--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;
|
||||
--dx-surface:#f3f3f1; --dx-elevated:#ffffff; --dx-text:#3b463d; --dx-muted:#6d766e;
|
||||
--dx-line:#d1d4cd; --dx-accent:#92b195; --dx-scroll-thumb:#c4c8c0;
|
||||
--dx-accent-weak:rgba(146,177,149,.18); --dx-hover:rgba(59,70,61,.06);
|
||||
/* --rd-* are the dashboard's semantic aliases; they RESOLVE to the tokens above so a panel
|
||||
written against either name inherits the same palette. */
|
||||
--rd-accent:#e67e22; --rd-info:#2b6cb0;
|
||||
--rd-line:var(--dx-line); --rd-bg:var(--dx-surface);
|
||||
--rd-ink:var(--dx-text); --rd-muted:var(--dx-muted); --rd-panel:var(--dx-elevated);
|
||||
--rd-primary:var(--dx-accent);
|
||||
--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; }
|
||||
|
|
@ -93,20 +118,24 @@ useShadowStyles(`
|
|||
.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;
|
||||
grid-template-areas: "topbar topbar topbar" "filters filters filters" "stats stats stats" "facets map rail" "facets table rail";
|
||||
gap:1px; background:var(--rd-line, #d1d4cd); container-type:inline-size;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
color:var(--rd-ink,#1f2733); }
|
||||
color:var(--rd-ink, #3b463d); }
|
||||
.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="filters"]{ grid-area:filters; 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{ display:flex; flex-direction:column; min-height:0; height:100%; background:var(--rd-bg, #f3f3f1); }
|
||||
.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; }
|
||||
/* Table collapsed → the grid+footer hide but the table HEADER BAR stays docked under the map,
|
||||
still showing the live matched count and the Export CSV button (card
|
||||
BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS). The row shrinks to the bar's height
|
||||
instead of 0, so the map still takes back nearly the whole cell via its 1fr. */
|
||||
.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr var(--table-bar-h,30px); }
|
||||
/* Facet/rail collapse — override the COLUMN template (not the --*-w vars) so collapse wins
|
||||
over a dragged inline width, exactly like the table row-template above. Map is minmax(0,1fr)
|
||||
and self-resizes via DashboardMapPanel's ResizeObserver → map.resize(). */
|
||||
|
|
@ -116,18 +145,13 @@ useShadowStyles(`
|
|||
/* 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;
|
||||
border:1px solid var(--dx-line,#d1d4cd); background:var(--dx-elevated,#fff); color:var(--dx-text,#3b463d);
|
||||
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-caret:hover{ background:var(--dx-hover,rgba(59,70,61,.06)); }
|
||||
.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); }
|
||||
/* (the floating .rd-table-restore pill is retired — the table's own always-docked header bar
|
||||
carries the chevron, the live count and Export CSV while collapsed) */
|
||||
.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
|
||||
|
|
@ -146,26 +170,45 @@ useShadowStyles(`
|
|||
.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 .n{ font-weight:800; font-size:15px; color:var(--rd-ink, #3b463d); transition:color .2s; }
|
||||
.rd-stats .kpi .l{ font-size:10px; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted, #6d766e); }
|
||||
.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"; }
|
||||
grid-template-areas:"topbar" "filters" "stats" "body" "seg"; }
|
||||
.rd-shell.rd-narrow > [data-region]{ grid-area:body; min-width:0; min-height:0; }
|
||||
.rd-shell.rd-narrow > [data-region="topbar"]{ grid-area:topbar; }
|
||||
.rd-shell.rd-narrow > [data-region="search"]{ grid-area:search; }
|
||||
.rd-shell.rd-narrow > [data-region="filters"]{ grid-area:filters; }
|
||||
.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-seg button{ flex:1; border:0; background:var(--dx-elevated,#fff); border-top:1px solid var(--rd-line,#d1d4cd); font-size:12px; font-weight:700; color:var(--dx-muted,#6d766e); cursor:pointer; }
|
||||
.rd-seg button.on{ color:var(--rd-ink, #3b463d); 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; }
|
||||
background:var(--rd-ink, #3b463d); 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; } }
|
||||
/* A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): while a divider is being dragged the grid tracks
|
||||
change every frame. Any transition on a descendant would be restarted every frame and never
|
||||
reach its end state, which reads as flicker. useDashboardLayout sets data-rd-resizing for the
|
||||
duration of the drag; kill all motion underneath it and re-enable on drag end. */
|
||||
.rd-shell[data-rd-resizing] *{ transition:none !important; animation:none !important; }
|
||||
/* Dragging over the map/table would otherwise start a text selection and repaint the selection
|
||||
highlight each frame — another flashing source. */
|
||||
.rd-shell[data-rd-resizing]{ user-select:none; -webkit-user-select:none; cursor:inherit; }
|
||||
|
||||
/* ── Thin overlay scrollbars (BUILD-MATERIAL-THIN-OVERLAY-SCROLLBARS) ──
|
||||
Material-light palette. Transparent track so the bar sits flush to the panel edge and costs
|
||||
no visual space; low-contrast thumb that darkens on hover. Applied to every scroll region in
|
||||
the shell so the rails, table and charts all match. */
|
||||
.rd-shell, .rd-shell *{ scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
|
||||
.rd-shell ::-webkit-scrollbar{ width:8px; height:8px; }
|
||||
.rd-shell ::-webkit-scrollbar-track{ background:transparent; }
|
||||
.rd-shell ::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
|
||||
.rd-shell ::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
|
||||
.rd-shell ::-webkit-scrollbar-corner{ background:transparent; }
|
||||
`, 'rd-shell-layout')
|
||||
</script>
|
||||
|
||||
|
|
@ -174,7 +217,7 @@ useShadowStyles(`
|
|||
: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 />
|
||||
<DashboardResearchFilters />
|
||||
<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>
|
||||
|
|
@ -185,8 +228,8 @@ useShadowStyles(`
|
|||
<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>
|
||||
<!-- (the floating "▴ Show table" restore button is gone: the table's own header bar — with its
|
||||
chevron, live count and Export CSV — now stays docked under the map while collapsed) -->
|
||||
<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>
|
||||
|
|
@ -194,7 +237,9 @@ useShadowStyles(`
|
|||
: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>
|
||||
<!-- The table CELL stays mounted+visible when collapsed; DashboardDataTable hides only its own
|
||||
grid+footer and keeps its header bar (count + Export CSV) docked below the map. -->
|
||||
<div class="rd-cell-table" data-region="table" v-show="showTable"><DashboardDataTable /></div>
|
||||
<aside class="rd-rail" data-region="rail" v-show="showCharts">
|
||||
<DashboardDetailDrawer />
|
||||
<DashboardChartsRail />
|
||||
|
|
|
|||
|
|
@ -6,10 +6,18 @@
|
|||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
const filterSet = bus?.filterSet || {}
|
||||
const crumbs = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, v: String(v) })))
|
||||
|
||||
// A crumb is a user-facing surface, so People CODES are resolved to names here exactly as the
|
||||
// filter chips do (dashboard.config.js:54-57 — codes must never reach a user-facing menu).
|
||||
// DISPLAY ONLY: `k` is what popTo() acts on and the stored value is never rewritten, so the bus's
|
||||
// byte-match contract is untouched.
|
||||
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
|
||||
const crumbs = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, v: String(labelFor(k, v)) })))
|
||||
|
||||
function popTo(k) {
|
||||
const keys = Object.keys(filterSet); const i = keys.indexOf(k)
|
||||
|
|
@ -24,10 +32,10 @@ function onClick(e) {
|
|||
}
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-breadcrumb{ display:flex; align-items:center; gap:4px; font-size:11px; color:#cdd3df; overflow:hidden; }
|
||||
.rd-breadcrumb{ display:flex; align-items:center; gap:4px; font-size:11px; color:var(--dx-line, #d1d4cd); 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-crumb.root{ color:var(--dx-muted, #6d766e); } .rd-crumb.cur{ color:#fff; font-weight:700; }
|
||||
.rd-sep{ opacity:.45; }
|
||||
`, 'rd-breadcrumb')
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { aggregate, buildDonut, buildBars, lbl } from '../../composables/useDashboardAggregations.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
|
|
@ -18,11 +19,13 @@ const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f
|
|||
// 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.
|
||||
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
|
||||
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 hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }) && matchSearch(r))
|
||||
}
|
||||
|
||||
const charts = computed(() => (cfg.charts || []).map((c) => {
|
||||
|
|
@ -51,31 +54,52 @@ function onChartClick(e) {
|
|||
}
|
||||
|
||||
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;
|
||||
/* SPACE ECONOMY (BUILD-SPACE-ECONOMY-SCROLLBARS-CHARTS-MAP): the rail is a narrow column, so
|
||||
every horizontal pixel of padding is taken from the charts themselves. Padding is trimmed
|
||||
12px -> 6px, and the RIGHT padding is dropped to 0 so the thin overlay scrollbar sits flush
|
||||
against the panel edge instead of floating in a gutter. The 6px is given back to the chart
|
||||
cards as their own right margin only when no scrollbar is present. */
|
||||
.rd-charts{ display:flex; flex-direction:column; gap:var(--s2,8px); height:100%; min-height:0;
|
||||
padding:6px 2px 6px 6px;
|
||||
background:var(--rd-panel,#fff); overflow-y:auto; overflow-x:hidden; box-sizing:border-box;
|
||||
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent;
|
||||
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-charts::-webkit-scrollbar{ width:8px; height:8px; }
|
||||
.rd-charts::-webkit-scrollbar-track{ background:transparent; }
|
||||
.rd-charts::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
|
||||
.rd-charts::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
|
||||
.rd-charts .rd-h{ font-weight:700; font-size:var(--fs-md,12px); text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted, #6d766e); }
|
||||
/* 10px -> 7px 6px: reclaims ~8px of width per card for the plot area itself. */
|
||||
.rd-chart{ border:1px solid var(--rd-line, #d1d4cd); border-radius:var(--r-lg,10px); background:var(--rd-bg, #f3f3f1); padding:7px 6px; }
|
||||
.rd-chart .rd-ct{ font-weight:700; font-size:var(--fs-sm,11px); color:var(--rd-ink, #3b463d); 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;
|
||||
.rd-slice.on, .rd-donut.has-sel .rd-slice.on{ opacity:1; stroke:var(--rd-ink, #3b463d); 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; }
|
||||
/* The donut legend is the WORST offender the Driver reported: a short, always-scrolling list
|
||||
whose default scrollbar ate a large share of an already narrow column. Thin overlay bar,
|
||||
transparent track, and no right padding so it hugs the card edge. */
|
||||
.rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:var(--dx-text, #3b463d); max-height:108px;
|
||||
overflow-y:auto; overflow-x:hidden; flex:1 1 auto; min-width:0;
|
||||
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
|
||||
.rd-legend::-webkit-scrollbar{ width:6px; height:6px; }
|
||||
.rd-legend::-webkit-scrollbar-track{ background:transparent; }
|
||||
.rd-legend::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:6px; }
|
||||
.rd-legend::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
|
||||
.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-li:hover{ background:var(--dx-surface, #f3f3f1); } .rd-li.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
|
||||
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; } .rd-li b{ margin-left:auto; color:var(--rd-muted, #6d766e); 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-bar:hover{ background:var(--dx-surface, #f3f3f1); } .rd-bar.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
|
||||
.rd-bl{ flex:0 0 84px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dx-text, #3b463d); }
|
||||
.rd-bt{ flex:1 1 auto; height:11px; background:var(--dx-surface, #f3f3f1); 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-bv{ flex:0 0 auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; min-width:30px; text-align:right; }
|
||||
`, 'rd-charts')
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@
|
|||
* '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 { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
|
|
@ -35,10 +38,12 @@ function matchFacet(r, key, val) {
|
|||
const rv = acc(r)
|
||||
return Array.isArray(val) ? val.includes(rv) : rv === val
|
||||
}
|
||||
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
|
||||
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])))
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return allRows.value
|
||||
return allRows.value.filter((r) => keys.every((k) => matchFacet(r, k, filterSet[k])) && matchSearch(r))
|
||||
})
|
||||
|
||||
// ── multi-sort (shift-click stacks; per-column 3-state none->asc->desc->none) ──
|
||||
|
|
@ -65,6 +70,40 @@ const sorted = computed(() => {
|
|||
})
|
||||
})
|
||||
|
||||
// ── selection views: "show only selected" + "selected first" ────────────────────────────────
|
||||
// Deliberately NOT written into filterSet: selection is ephemeral session state while filters
|
||||
// are shareable view config (they serialize to the URL hash), so folding a transient pick into
|
||||
// a filter would leak it into a shared link. These are view toggles layered ON TOP of the
|
||||
// filter pipeline. Declared here because the pipeline below consumes them.
|
||||
const onlySelected = ref(false)
|
||||
const selectedFirst = ref(false)
|
||||
function toggleOnlySelected() { onlySelected.value = !onlySelected.value }
|
||||
function toggleSelectedFirst() { selectedFirst.value = !selectedFirst.value }
|
||||
|
||||
// `selectionVersion` is the reactive handle on the (deliberately non-reactive) selectionSet —
|
||||
// see the performance contract in useSelectionBus.js. Every computed reading the set calls this.
|
||||
const selVer = () => { if (bus && bus.selectionVersion) return bus.selectionVersion.value; return 0 }
|
||||
|
||||
// Pipeline: filtered -> sorted -> visibleRows (selection views) -> windowRows (virtualization).
|
||||
// The views sit HERE, between sort and virtualization, so the window slices the post-view list
|
||||
// and 'show only selected' genuinely removes rows instead of just hiding painted ones.
|
||||
const visibleRows = computed(() => {
|
||||
let list = sorted.value
|
||||
if (onlySelected.value) {
|
||||
selVer()
|
||||
list = list.filter((r) => selectionSet && selectionSet.has(r.slug))
|
||||
}
|
||||
if (selectedFirst.value) {
|
||||
selVer()
|
||||
// Prepended sort key, stable within each group: composes with the user's column sort
|
||||
// instead of overriding it (selected rows float to the top, order otherwise preserved).
|
||||
const sel = [], rest = []
|
||||
for (const r of list) (selectionSet && selectionSet.has(r.slug) ? sel : rest).push(r)
|
||||
list = sel.concat(rest)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
// ── windowed virtualization (2,106 rows; render only the visible slice) ──
|
||||
const ROW_H = 26
|
||||
const scrollTop = ref(0)
|
||||
|
|
@ -74,9 +113,9 @@ 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 windowRows = computed(() => visibleRows.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))
|
||||
const padBot = computed(() => Math.max(0, (visibleRows.value.length - (start.value + windowRows.value.length)) * ROW_H))
|
||||
onMounted(() => {
|
||||
if (scroller.value) {
|
||||
viewH.value = scroller.value.clientHeight || 360
|
||||
|
|
@ -89,29 +128,201 @@ onMounted(() => {
|
|||
// 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)
|
||||
// index into the RENDERED list (visibleRows), not `sorted` — with a selection view active
|
||||
// the two differ, and a `sorted` index would scroll to the wrong offset.
|
||||
const idx = visibleRows.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())
|
||||
onBeforeUnmount(() => { ro && ro.disconnect(); clearTimeout(_toastT) })
|
||||
|
||||
// ── selection (writes to selectionSet via the bus) ──
|
||||
const selCount = computed(() => (selectionSet ? selectionSet.size : 0))
|
||||
// ── selection (writes to selectionSet via the bus) ──────────────────────────────────────────
|
||||
const selCount = computed(() => { selVer(); return selectionSet ? selectionSet.size : 0 })
|
||||
const isSel = (r) => !!(selectionSet && selectionSet.has(r.slug))
|
||||
// Row renders read through this so the version dep is registered in the render effect.
|
||||
const selTick = computed(() => selVer())
|
||||
|
||||
/**
|
||||
* Selection model — the Windows-Explorer semantics formalized in Järvi & Parent,
|
||||
* "One Way to Select Many" (ECOOP 2016). Explorer's rules are chosen over Finder's because
|
||||
* Finder's anchor placement is documented there as producing user-visible asymmetries.
|
||||
*
|
||||
* plain click → select ONLY this row; anchor = row
|
||||
* ctrl/cmd + click → toggle THIS row, keep the rest; anchor = row (ctrl DOES move the anchor);
|
||||
* mode = 'select' if the row was unselected, else 'deselect'
|
||||
* shift + click → REPLACE the selection with range(anchor → row); anchor stays PINNED
|
||||
* ctrl+shift+click → ADD (or, in deselect mode, SUBTRACT) range(anchor → row); anchor pinned
|
||||
*
|
||||
* `anchorSlug` is a STABLE SLUG, never a row index: the grid is virtualized AND sortable, so an
|
||||
* index would silently point at a different people group after a re-sort or a DOM recycle.
|
||||
*
|
||||
* `baseSelection` is the selection as it stood when the anchor was set. Every shift+click
|
||||
* recomputes `base ⊕ range` from scratch instead of diffing against the previous range, which
|
||||
* makes shift+click idempotent: dragging a range back SHRINKS it and restores what was there
|
||||
* before, rather than leaving rows stranded (the Finder bug the paper calls out).
|
||||
*/
|
||||
const anchorSlug = ref(null)
|
||||
const anchorMode = ref('select') // 'select' | 'deselect' — set by ctrl+click, read by ctrl+shift
|
||||
let baseSelection = new Set() // plain Set, non-reactive: pure bookkeeping
|
||||
|
||||
function setAnchor(slug, mode) {
|
||||
anchorSlug.value = slug
|
||||
anchorMode.value = mode
|
||||
baseSelection = new Set(selectionSet)
|
||||
}
|
||||
// Range over the CURRENTLY VISIBLE order (filtered + sorted + selection views) — the order the
|
||||
// user actually sees on screen, never the underlying dataset order.
|
||||
function rangeSlugs(fromSlug, toSlug) {
|
||||
const list = visibleRows.value
|
||||
const i = list.findIndex((r) => r.slug === fromSlug)
|
||||
const j = list.findIndex((r) => r.slug === toSlug)
|
||||
if (i < 0 || j < 0) return []
|
||||
const [lo, hi] = i <= j ? [i, j] : [j, i]
|
||||
const out = []
|
||||
for (let k = lo; k <= hi; k++) out.push(list[k].slug)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE selection gesture = ONE bulk commit. Never loops toggleSelect(): that is precisely the
|
||||
* 20-second freeze (BUG-TABLE-BATCH-SELECTION-FREEZES-20S) — 2,107 synchronous map rebuilds.
|
||||
*/
|
||||
function applyClick(slug, ev) {
|
||||
if (!bus || !slug) return
|
||||
const ctrl = !!(ev && (ev.ctrlKey || ev.metaKey))
|
||||
const shift = !!(ev && ev.shiftKey)
|
||||
|
||||
if (shift && anchorSlug.value) {
|
||||
const range = rangeSlugs(anchorSlug.value, slug)
|
||||
if (!range.length) return
|
||||
if (ctrl) {
|
||||
// ctrl+shift: apply the range ON TOP of the base snapshot, honoring the ctrl mode.
|
||||
const next = new Set(baseSelection)
|
||||
if (anchorMode.value === 'deselect') for (const s of range) next.delete(s)
|
||||
else for (const s of range) next.add(s)
|
||||
bus.setSelection(next)
|
||||
} else {
|
||||
// plain shift: the range REPLACES the selection (Explorer/Finder rule).
|
||||
bus.setSelection(range)
|
||||
}
|
||||
return // anchor stays pinned so the next shift+click re-extends from the same pivot
|
||||
}
|
||||
|
||||
if (ctrl) {
|
||||
const willSelect = !selectionSet.has(slug)
|
||||
setAnchor(slug, willSelect ? 'select' : 'deselect')
|
||||
bus.toggleSelect(slug)
|
||||
// re-snapshot AFTER the toggle so a following ctrl+shift extends from the post-toggle state
|
||||
baseSelection = new Set(selectionSet)
|
||||
return
|
||||
}
|
||||
|
||||
// plain click → this row only
|
||||
setAnchor(slug, 'select')
|
||||
bus.setSelection([slug])
|
||||
baseSelection = new Set(selectionSet)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// receives the bubbled click; resolve the clicked row via data-slug, then apply the gesture.
|
||||
//
|
||||
// The CHECKBOX CELL is the exception: it always TOGGLES one row and never clears the rest
|
||||
// (AG Grid / Helios convention — the checkbox is the "add to selection" affordance, the row
|
||||
// body is the "replace selection" affordance).
|
||||
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 t = e.target
|
||||
if (!t || !t.closest) return
|
||||
const rowEl = t.closest('.rd-row')
|
||||
if (!rowEl || !bus) return
|
||||
const slug = rowEl.getAttribute('data-slug')
|
||||
if (!slug) return
|
||||
if (t.closest('.rd-td.chk')) {
|
||||
// Checkbox column. A MODIFIER always wins: shift must extend the range and ctrl must
|
||||
// toggle-with-anchor-move on the checkbox exactly as it does on the row body, or this
|
||||
// 30px-wide column becomes a dead zone where the keyboard is silently ignored — the
|
||||
// branch below is modifier-blind, so without this line a shift+click on a checkbox is
|
||||
// just a plain additive toggle (BUILD-TABLE-PRO-SELECTION-SHIFT-CTRL).
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) { applyClick(slug, e); return }
|
||||
// Unmodified: plain toggle, selection preserved; still moves the anchor (ctrl-like).
|
||||
setAnchor(slug, selectionSet.has(slug) ? 'deselect' : 'select')
|
||||
bus.toggleSelect(slug)
|
||||
baseSelection = new Set(selectionSet)
|
||||
return
|
||||
}
|
||||
applyClick(slug, e)
|
||||
}
|
||||
const allVisibleSelected = computed(() => filtered.value.length > 0 && filtered.value.every((r) => isSel(r)))
|
||||
// shift+click natively extends the browser's TEXT selection, smearing blue highlight across
|
||||
// rows. preventDefault on mousedown kills the range extension at source while leaving the
|
||||
// click event (and ordinary text copy/paste without shift) completely intact.
|
||||
function onGridMouseDown(e) {
|
||||
if (e.shiftKey && e.target && e.target.closest && e.target.closest('.rd-row')) e.preventDefault()
|
||||
}
|
||||
|
||||
// Header checkbox scope = the CURRENT FILTER (AG Grid's selectAll:'filtered'). Selecting rows
|
||||
// the user has filtered away is the classic bulk-action footgun.
|
||||
const allVisibleSelected = computed(() => {
|
||||
selVer()
|
||||
const f = filtered.value
|
||||
if (!f.length) return false
|
||||
for (const r of f) if (!selectionSet.has(r.slug)) return false
|
||||
return true
|
||||
})
|
||||
// Tri-state: some-but-not-all → indeterminate. Derived, never stored.
|
||||
const someVisibleSelected = computed(() => {
|
||||
selVer()
|
||||
if (allVisibleSelected.value) return false
|
||||
for (const r of filtered.value) if (selectionSet.has(r.slug)) return true
|
||||
return false
|
||||
})
|
||||
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))
|
||||
const slugs = filtered.value.map((r) => r.slug)
|
||||
// ONE bulk commit each way — this is the fix for the 20s freeze.
|
||||
if (allVisibleSelected.value) bus.deselectMany(slugs)
|
||||
else bus.selectMany(slugs)
|
||||
anchorSlug.value = null
|
||||
baseSelection = new Set(selectionSet)
|
||||
}
|
||||
function clearSel() { bus && bus.clearSelection() }
|
||||
function clearSel() {
|
||||
bus && bus.clearSelection()
|
||||
anchorSlug.value = null
|
||||
baseSelection = new Set()
|
||||
}
|
||||
|
||||
// `indeterminate` is a JS-only property on <input type=checkbox> — there is no HTML attribute
|
||||
// and it cannot be set from CSS — so bind the element and drive it imperatively. The browser
|
||||
// then exposes aria-checked="mixed" itself, which is why we do NOT hand-set aria-checked.
|
||||
const allChkEl = ref(null)
|
||||
function setAllChkEl(el) { allChkEl.value = el || null }
|
||||
watch([someVisibleSelected, allChkEl], () => {
|
||||
if (allChkEl.value) allChkEl.value.indeterminate = someVisibleSelected.value
|
||||
}, { immediate: true })
|
||||
|
||||
// A range/anchor defined over one row order is meaningless in another — drop it when the user
|
||||
// re-sorts or the visible set changes underneath them. (Not watched on `selectionSet` itself:
|
||||
// selecting is exactly when the anchor must SURVIVE.)
|
||||
//
|
||||
// Watch a cheap ORDER FINGERPRINT, never `filtered` itself. Watching the computed directly
|
||||
// compares ARRAY IDENTITY, and on the filtered path `filtered` allocates a NEW array on every
|
||||
// re-evaluation (the no-filter path returns `allRows.value` unchanged, which is why this only
|
||||
// ever bit with a search/facet active). Committing a selection re-runs the chain, yields a
|
||||
// fresh array, and nulls the anchor the very click just set — the gesture ate its own anchor.
|
||||
// Measured with trusted events at 1440x1200: with a search term active, click row 3 then
|
||||
// shift+click row 8 gave 1 -> 1 (no range at all); it now gives the correct 1 -> 6.
|
||||
//
|
||||
// `filtered.value.length` is O(1) and changes whenever the filter/search genuinely narrows the
|
||||
// set; sort + the two view toggles cover every other reorder. A same-length filter swap is the
|
||||
// one case this misses — far cheaper than hashing 2,107 slugs on every selection commit, which
|
||||
// would put real work back on the hot path the performance contract exists to protect.
|
||||
watch(
|
||||
[sortState, () => filtered.value.length, onlySelected, selectedFirst],
|
||||
() => { anchorSlug.value = null },
|
||||
)
|
||||
|
||||
// "Show only selected" with nothing selected would render an empty grid and look broken —
|
||||
// drop back to the full view automatically when the selection is cleared.
|
||||
watch(selCount, (n) => { if (!n) onlySelected.value = false })
|
||||
|
||||
// ── 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. ──
|
||||
|
|
@ -122,47 +333,143 @@ function toggleDock() { collapsed.value = !collapsed.value; bus && bus.emit('tog
|
|||
const sumPop = computed(() => filtered.value.reduce((s, r) => s + (num(r.population) || 0), 0))
|
||||
const rowCount = computed(() => filtered.value.length)
|
||||
|
||||
// ── CSV export (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
|
||||
// Moved here from the dashboard topbar: the button exports THIS table, so it belongs on this
|
||||
// table's header bar. The bar is deliberately rendered OUTSIDE the collapse, so the live count +
|
||||
// this button stay docked under the map when the grid is hidden.
|
||||
// The exporter honors selection > filter > all and fetches the ALL-FIELDS record lazily.
|
||||
// labelFor resolves People CODES to names in the export's provenance COMMENT line only — the data
|
||||
// columns are untouched.
|
||||
const { labelFor: csvLabelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
|
||||
const csv = dash && bus
|
||||
? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC: FACETS, dataOpts: cfg.data, labelFor: csvLabelFor })
|
||||
: null
|
||||
const exportToast = ref('')
|
||||
const exporting = ref(false)
|
||||
let _toastT = null
|
||||
async function onExport() {
|
||||
if (!csv || exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const r = await csv.exportCsvAsync()
|
||||
const how = r.downloaded ? 'exported' : r.clipboard ? 'copied to clipboard' : 'ready'
|
||||
exportToast.value = `✓ ${fmt(r.rows)} rows × ${r.fields} fields ${how}`
|
||||
} catch (e) {
|
||||
exportToast.value = '✕ Export failed'
|
||||
} finally {
|
||||
exporting.value = false
|
||||
clearTimeout(_toastT); _toastT = setTimeout(() => { exportToast.value = '' }, 3200)
|
||||
}
|
||||
}
|
||||
// Warm the all-fields cache on hover so the first real click is already wide (and keeps its
|
||||
// clipboard user-activation instead of spending it on the network round-trip).
|
||||
function onExportHover() { csv && csv.prefetchExportFields() }
|
||||
|
||||
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-table-bar{ display:flex; align-items:center; gap:10px; padding:4px 10px; border-bottom:1px solid var(--dx-line, #d1d4cd); flex:0 0 auto; }
|
||||
.rd-table-bar .ttl{ font-weight:700; color:var(--rd-ink, #3b463d); } .rd-table-bar .ct{ color:var(--dx-muted, #6d766e); }
|
||||
.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; }
|
||||
color:var(--rd-muted, #6d766e); font-size:12px; }
|
||||
.rd-dock:hover{ background:var(--rd-bg, #f3f3f1); color:var(--rd-accent,#e67e22); }
|
||||
/* selection-count badge — the standard tint/ink/border badge recipe, resolved through the
|
||||
sage tokens instead of the blue tint-set it was authored with: weak accent fill,
|
||||
primary ink, hairline border. */
|
||||
.rd-pill{ display:inline-flex; align-items:center; gap:6px; font-weight:700; font-size:11px; padding:3px 9px; border-radius:999px;
|
||||
background:var(--dx-accent-weak, rgba(146,177,149,.18)); color:var(--dx-text, #3b463d); border:1px solid var(--dx-line, #d1d4cd); }
|
||||
.rd-pill button{ all:unset; cursor:pointer; color:var(--dx-text, #3b463d); opacity:.7; } .rd-pill button:hover{ opacity:1; }
|
||||
/* Selection VIEW toggles (only-selected / selected-first). Deliberately slim + quiet: they sit
|
||||
beside the count pill but must not compete with Export CSV for attention. Material-light DX
|
||||
palette — surface #f3f3f1, border #d1d4cd, ink #3b463d, primary #92b195 when engaged. */
|
||||
.rd-table-bar .rd-vtog{ all:unset; box-sizing:border-box; cursor:pointer; flex:0 0 auto; user-select:none;
|
||||
font-size:10px; font-weight:700; letter-spacing:.2px; color:var(--dx-muted,#6d766e);
|
||||
background:var(--dx-surface,#f3f3f1); border:1px solid var(--dx-line,#d1d4cd);
|
||||
border-radius:var(--r-pill,999px); padding:3px 9px; line-height:14px; white-space:nowrap; }
|
||||
.rd-table-bar .rd-vtog:hover{ color:var(--dx-text,#3b463d); border-color:#b9bdb4; }
|
||||
.rd-table-bar .rd-vtog.on{ color:#2b352c; background:var(--rd-primary,#92b195); border-color:#84a487; }
|
||||
.rd-table-bar .rd-vtog:focus-visible{ outline:2px solid #6d8a70; outline-offset:2px; }
|
||||
/* pushes the export cluster to the right edge of the bar (the pill used to hold margin-left:auto) */
|
||||
.rd-bar-sp{ flex:1 1 auto; }
|
||||
/* Export CSV — lives on THIS bar now (moved from the dashboard topbar). Material-light DX palette:
|
||||
primary #92b195 surface, deep ink label, border #d1d4cd. Stays visible while collapsed. */
|
||||
.rd-table-bar .rd-csv{ display:inline-flex; align-items:center; gap:6px; flex:0 0 auto; cursor:pointer; user-select:none;
|
||||
font-size:11px; font-weight:700; letter-spacing:.2px; color:#2b352c; background:var(--rd-primary,#92b195);
|
||||
border:1px solid #84a487; border-radius:var(--r-md,6px); padding:4px 11px; }
|
||||
.rd-table-bar .rd-csv:hover{ background:#84a487; color:#1f271f; }
|
||||
.rd-table-bar .rd-csv:focus-visible{ outline:2px solid #6d8a70; outline-offset:2px; }
|
||||
.rd-table-bar .rd-csv.busy{ opacity:.65; cursor:progress; }
|
||||
.rd-table-bar .rd-csv-n{ background:#ffffff66; border-radius:var(--r-pill,999px); padding:0 6px; font-size:10px; }
|
||||
.rd-table-bar .rd-toast{ flex:0 0 auto; font-size:11px; font-weight:700; color:#33603f; background:#eaf3ec;
|
||||
border:1px solid #c3dbc7; border-radius:var(--r-md,6px); padding:3px 9px; white-space:nowrap; }
|
||||
/* Thin overlay scrollbars on the virtualized grid. The table scrolls BOTH axes, so the corner
|
||||
is made transparent too or it renders as a grey notch against the sticky header
|
||||
(BUILD-MATERIAL-THIN-OVERLAY-SCROLLBARS). */
|
||||
.rd-grid{ flex:1 1 auto; min-height:0; overflow:auto; position:relative;
|
||||
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
|
||||
.rd-grid::-webkit-scrollbar{ width:8px; height:8px; }
|
||||
.rd-grid::-webkit-scrollbar-track{ background:transparent; }
|
||||
.rd-grid::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
|
||||
.rd-grid::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
|
||||
.rd-grid::-webkit-scrollbar-corner{ background:transparent; }
|
||||
.rd-thead{ position:sticky; top:0; z-index:2; display:flex; background:var(--dx-surface, #f3f3f1); border-bottom:1px solid var(--dx-line, #d1d4cd); }
|
||||
.rd-th{ flex:1 1 0; min-width:0; padding:3px 8px; font-weight:700; color:var(--dx-text, #3b463d); 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; }
|
||||
/* sort indicator — the glyph carries the meaning; colour only needs to read as ACTIVE
|
||||
against the muted header row, so it takes primary ink rather than a hue of its own. */
|
||||
.rd-th .ind{ font-size:9px; color:var(--dx-text, #3b463d); margin-left:3px; }
|
||||
.rd-row{ display:flex; height:26px; line-height:16px; border-bottom:1px solid var(--dx-surface, #f3f3f1); align-items:center; }
|
||||
.rd-row:hover{ background:var(--dx-surface, #f3f3f1); } .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:var(--dx-text, #3b463d); }
|
||||
.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-foot{ flex:0 0 auto; display:flex; border-top:2px solid var(--dx-line, #d1d4cd); background:var(--dx-surface, #f3f3f1); font-weight:700; height:28px; align-items:center; color:var(--dx-text, #3b463d); }
|
||||
.rd-foot .rd-td{ color:var(--dx-text, #3b463d); }
|
||||
`, 'rd-datatable')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-table-wrap" :data-sel-count="selCount">
|
||||
<div class="rd-table-bar">
|
||||
<!-- Header bar — ALWAYS rendered, collapsed or not. The count + Export CSV are the two
|
||||
controls the Driver asked to keep docked under the map when the grid is hidden. -->
|
||||
<div class="rd-table-bar" :data-collapsed="collapsed ? '1' : '0'">
|
||||
<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 class="ct" data-table-count>{{ fmt(rowCount) }} {{ rowCount === 1 ? 'group' : 'groups' }}</span>
|
||||
<!-- role=status so assistive tech announces the count as the selection changes. -->
|
||||
<span v-if="selCount" class="rd-pill" data-sel-pill role="status">
|
||||
<span>{{ fmt(selCount) }} selected</span>
|
||||
<button title="Clear selection" aria-label="Clear selection" @click="clearSel">✕</button>
|
||||
</span>
|
||||
<!-- Selection VIEWS — separated from the pill because they mutate the VIEW, not the
|
||||
selection. Only meaningful once something is selected, so they appear with it. -->
|
||||
<template v-if="selCount">
|
||||
<button class="rd-vtog" :class="{ on: onlySelected }" data-only-selected
|
||||
:aria-pressed="onlySelected ? 'true' : 'false'"
|
||||
title="Show only the selected people groups" @click="toggleOnlySelected">Only selected</button>
|
||||
<button class="rd-vtog" :class="{ on: selectedFirst }" data-selected-first
|
||||
:aria-pressed="selectedFirst ? 'true' : 'false'"
|
||||
title="Float the selected people groups to the top, keeping your column sort" @click="toggleSelectedFirst">Selected first</button>
|
||||
</template>
|
||||
<span class="rd-bar-sp"></span>
|
||||
<span v-if="exportToast" class="rd-toast" data-export-toast>{{ exportToast }}</span>
|
||||
<span class="rd-csv" :class="{ busy: exporting }" role="button" tabindex="0" data-csv-btn
|
||||
:aria-busy="exporting ? 'true' : 'false'"
|
||||
:title="selCount ? `Export the ${selCount} selected people groups — all fields`
|
||||
: `Export these ${fmt(rowCount)} people groups — all fields`"
|
||||
@click="onExport" @mouseenter="onExportHover" @focus="onExportHover"
|
||||
@keydown.enter.prevent="onExport" @keydown.space.prevent="onExport">
|
||||
{{ exporting ? '…' : '↓' }} Export CSV<span v-if="selCount" class="rd-csv-n">{{ selCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref="scroller" class="rd-grid" role="grid" :aria-rowcount="rowCount + 1" @scroll="onScroll" @click="onGridClick">
|
||||
<div v-show="!collapsed" ref="scroller" class="rd-grid" role="grid" aria-multiselectable="true"
|
||||
:aria-rowcount="visibleRows.length + 1" @scroll="onScroll" @click="onGridClick" @mousedown="onGridMouseDown">
|
||||
<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 class="rd-th chk" style="cursor:pointer" :title="allVisibleSelected ? 'Deselect all ' + fmt(rowCount) : 'Select all ' + fmt(rowCount)" @click="toggleAll">
|
||||
<!-- tri-state: `indeterminate` is a JS-only property (no HTML attribute), so it is set
|
||||
via a ref callback; the browser then reports aria-checked="mixed" for us. -->
|
||||
<input type="checkbox" :checked="allVisibleSelected" :ref="setAllChkEl"
|
||||
:aria-label="allVisibleSelected ? 'Deselect all rows' : 'Select all rows'" 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)">
|
||||
|
|
@ -171,8 +478,11 @@ useShadowStyles(`
|
|||
</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'">
|
||||
<!-- `selTick` is read in the row key-path so the render effect subscribes to
|
||||
selectionVersion (selectionSet itself is a plain, non-reactive Set). -->
|
||||
<div v-for="(r, i) in windowRows" :key="r.slug" class="rd-row" :class="{ sel: selTick >= 0 && isSel(r) }" role="row"
|
||||
:data-slug="r.slug" :aria-selected="isSel(r) ? 'true' : 'false'"
|
||||
:aria-rowindex="start + i + 2">
|
||||
<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>
|
||||
|
|
@ -183,7 +493,7 @@ useShadowStyles(`
|
|||
<div :style="{ height: padBot + 'px' }"></div>
|
||||
</div>
|
||||
|
||||
<div class="rd-foot" role="row">
|
||||
<div v-show="!collapsed" 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>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
|
|
@ -22,8 +23,11 @@ const bus = inject('selectionBus', null)
|
|||
const cfg = inject('dashboardConfig', {})
|
||||
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
// labelFor resolves People CODES to names in the export's provenance COMMENT line only — the data
|
||||
// columns are untouched.
|
||||
const { labelFor: csvLabelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
|
||||
const exporter = (dash && bus)
|
||||
? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC })
|
||||
? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, labelFor: csvLabelFor })
|
||||
: null
|
||||
|
||||
const activeSlug = ref(null)
|
||||
|
|
@ -101,31 +105,31 @@ function clearDetail() {
|
|||
}
|
||||
|
||||
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{ box-sizing:border-box; background:var(--rd-panel,#fff); border-left:1px solid var(--rd-line, #d1d4cd);
|
||||
border-bottom:1px solid var(--rd-line, #d1d4cd); padding:var(--s3,12px);
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; color:var(--rd-ink, #3b463d); 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); }
|
||||
color:var(--rd-muted, #6d766e); 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; }
|
||||
font-size:15px; color:var(--rd-muted, #6d766e); border-radius:var(--r-md,6px); flex-shrink:0; }
|
||||
.rd-detail .rd-detail-close:hover{ color:var(--rd-ink, #3b463d); background:var(--rd-bg, #f3f3f1); }
|
||||
.rd-detail .rd-empty{ font-size:var(--fs-md,12px); color:var(--rd-muted, #6d766e); 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; }
|
||||
border:1px solid var(--rd-line, #d1d4cd); display:block; }
|
||||
.rd-detail .rd-ph{ width:64px; height:64px; border-radius:var(--r-md,6px); flex:0 0 auto; background:var(--rd-bg, #f3f3f1);
|
||||
border:1px solid var(--rd-line, #d1d4cd); display:flex; align-items:center; justify-content:center;
|
||||
color:var(--rd-muted, #6d766e); 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, #3b463d); }
|
||||
.rd-detail .rd-meta{ font-size:var(--fs-sm,11px); color:var(--rd-muted, #6d766e); 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); }
|
||||
padding:2px 0; border-bottom:1px solid var(--rd-line, #d1d4cd); }
|
||||
.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-row .k{ color:var(--rd-muted, #6d766e); flex:0 0 auto; min-width:118px; }
|
||||
.rd-detail .rd-row .v{ color:var(--rd-ink, #3b463d); margin-left:auto; text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.rd-detail .rd-row .v .fam{ color:var(--rd-muted, #6d766e); }
|
||||
.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; }
|
||||
|
|
@ -134,11 +138,11 @@ useShadowStyles(`
|
|||
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{ 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-detail .rd-act-info{ flex:0 1 auto; background:var(--rd-bg, #f3f3f1); color:var(--rd-ink, #3b463d);
|
||||
border:1px solid var(--rd-line, #d1d4cd); }
|
||||
.rd-detail .rd-act-info:hover{ background:var(--rd-panel,#fff); border-color:var(--rd-muted, #6d766e); }
|
||||
`, 'rd-detaildrawer')
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@
|
|||
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'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
import { useTreeLegendColors } from '../../composables/useTreeLegendColors.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
|
|
@ -30,17 +32,43 @@ 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).
|
||||
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
|
||||
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 hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }) && matchSearch(r))
|
||||
}
|
||||
|
||||
// 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] }
|
||||
|
||||
// ── PER-NODE EXPAND/COLLAPSE, SEPARATE FROM FILTERING (card BUILD-STACKED-TREE-LEGEND) ──────
|
||||
// Previously a branch opened ONLY when its row was `active`, i.e. the ONLY way to see a region's
|
||||
// blocs was to FILTER the whole dashboard to that region. That conflates two different intents —
|
||||
// "show me what's inside this" (navigation) vs "narrow everything to this" (selection) — and makes
|
||||
// the tree impossible to browse: you cannot compare two branches without re-filtering between them.
|
||||
//
|
||||
// So expansion is now its OWN state, keyed by `facet:value` (globally unique across all four
|
||||
// trees, since a facet key belongs to exactly one tree). The caret toggles expansion; the row
|
||||
// body still toggles the filter. A node the user has never touched falls back to "expanded iff it
|
||||
// is the active filter" (isExpandedRaw), so the old auto-open-the-filtered-branch behaviour is
|
||||
// preserved exactly as a special case.
|
||||
const expanded = reactive({})
|
||||
const nodeKey = (facet, value) => facet + ':' + value
|
||||
function toggleExpand(facet, value) {
|
||||
const k = nodeKey(facet, value)
|
||||
expanded[k] = !isExpandedRaw(facet, value)
|
||||
}
|
||||
function isExpandedRaw(facet, value) {
|
||||
const k = nodeKey(facet, value)
|
||||
// undefined = never touched → fall back to "expanded iff this node is the active filter",
|
||||
// so an active branch still opens itself exactly as it always did.
|
||||
return k in expanded ? expanded[k] : filterSet[facet] === value
|
||||
}
|
||||
|
||||
function toRows(agg, facet, level) {
|
||||
const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1
|
||||
return agg.map((d) => ({
|
||||
|
|
@ -49,48 +77,140 @@ function toRows(agg, facet, level) {
|
|||
}))
|
||||
}
|
||||
|
||||
// ── single flat facet list (e.g. Religion / Status) ──
|
||||
// ── single flat facet list (e.g. 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.
|
||||
// ── multi-level drill (e.g. Place: region → bloc → country) ──────────────────────────────────
|
||||
// Excludes drill.slice(level) at each level so the list under the active parent stays navigable:
|
||||
// the parent's own filter REMAINS applied (it sits above `level`), so children always sum exactly
|
||||
// to their parent's count. Verified against the live API — picking region "Asia" yields blocs
|
||||
// summing to 1,309, which is exactly the number of Asia rows.
|
||||
//
|
||||
// A branch now descends when the node is EXPANDED (see isExpandedRaw) rather than only when it is
|
||||
// the active filter — so several branches can be open and compared at once without filtering.
|
||||
// That is only safe because of `parentChain`: rowsExcluding(drill.slice(level)) drops the parent
|
||||
// tier's predicate, so without an explicit ancestor scope an expanded-but-unfiltered node would
|
||||
// list the ENTIRE next level instead of its own children. The chain re-applies exactly the
|
||||
// ancestors we descended through, which is what keeps children summing to their parent.
|
||||
function buildDrillRows(drill, topN) {
|
||||
const out = []
|
||||
const walk = (level) => {
|
||||
const walk = (level, parentChain) => {
|
||||
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)
|
||||
// Rows for this level, scoped to the ancestor chain the caller descended through. The chain
|
||||
// is applied as an extra predicate so an expanded-but-not-filtered parent still shows only
|
||||
// ITS children — expansion never widens the set the way a missing predicate would.
|
||||
let base = rowsExcluding(drill.slice(level))
|
||||
if (parentChain.length) {
|
||||
base = base.filter((r) => parentChain.every(([k, v]) => { const a = FACET_ACC[k]; return a ? a(r) === v : true }))
|
||||
}
|
||||
const rows = toRows(aggregate(base, FACET_ACC[facet], cap), facet, level)
|
||||
for (const row of rows) {
|
||||
// a row can be drilled into iff it is not the 'Other' bucket and a deeper level exists
|
||||
row.hasChildren = !row.other && level < drill.length - 1
|
||||
row.expanded = row.hasChildren && isExpandedRaw(row.facet, row.value)
|
||||
out.push(row)
|
||||
if (row.active && level < drill.length - 1) walk(level + 1)
|
||||
if (row.expanded) walk(level + 1, [...parentChain, [facet, row.value]])
|
||||
}
|
||||
}
|
||||
walk(0)
|
||||
walk(0, [])
|
||||
return out
|
||||
}
|
||||
|
||||
// ── TREE TRUNKS (card BUILD-TREE-LEGEND-SEMANTIC-INDENTED) ────────────────────────────────
|
||||
// Driver: "it is very hard for me to read the content because I cannot see that it is INDENTED
|
||||
// like semantic trees. I want the semantic tree legend on the left just like on the research maps."
|
||||
//
|
||||
// Root cause: the rail conveyed depth with padding-left ALONE (.l0/.l1/.l2). Whitespace does not
|
||||
// read as hierarchy — nothing visually ties a child row to its parent, so People > bloc > cluster
|
||||
// and Place > region > bloc > country looked like one flat list of unrelated rows.
|
||||
//
|
||||
// Fix: reuse the research-map semantic tree's ACTUAL indentation model rather than inventing a
|
||||
// second one. This is a direct port of SemanticTreeLegend.visibleRows' trunk pass
|
||||
// (@map/components/legends/SemanticTreeLegend.vue) — per row it emits one segment per ancestor
|
||||
// level, drawn by the SAME .stl-trunk-{pipe,tee,elbow,blank} CSS vocabulary (a 1px rule at
|
||||
// left:5px, with a 7px horizontal stub at the row's midpoint):
|
||||
// pipe │ an ancestor's branch continues past this row
|
||||
// tee ├ this row has a later sibling
|
||||
// elbow └ this row is the last child of its parent
|
||||
// blank the ancestor's branch ended above
|
||||
// The upstream walks a real node tree (relDepth/ancestors); the rail's rows are a FLAT list
|
||||
// carrying `level`, so "has a later sibling" = the next row at the same level before any
|
||||
// shallower row, and "ancestor continues" = some later row is deeper than L before the tree
|
||||
// returns to L or above. Same output shape (`row.trunks`), same renderer, same look.
|
||||
function addTrunks(rows) {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]
|
||||
const segs = []
|
||||
for (let L = 0; L < row.level; L++) {
|
||||
if (L === row.level - 1) {
|
||||
// deepest segment: does another row share this row's parent below it?
|
||||
let hasLaterSibling = false
|
||||
for (let j = i + 1; j < rows.length; j++) {
|
||||
if (rows[j].level < row.level) break
|
||||
if (rows[j].level === row.level) { hasLaterSibling = true; break }
|
||||
}
|
||||
segs.push(hasLaterSibling ? 'tee' : 'elbow')
|
||||
} else {
|
||||
// an ancestor level: its branch continues iff a later row sits at that level again
|
||||
let trunkContinues = false
|
||||
for (let j = i + 1; j < rows.length; j++) {
|
||||
if (rows[j].level <= L) { trunkContinues = rows[j].level === L; break }
|
||||
}
|
||||
segs.push(trunkContinues ? 'pipe' : 'blank')
|
||||
}
|
||||
}
|
||||
row.trunks = segs
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// The People facets key by CODE (from config.facets). Those codes → human names are resolved 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) }
|
||||
}
|
||||
// The maps were built inline here, which made this rail the ONLY surface that projected them —
|
||||
// the filter chip bar rendered the raw code. They now live in useTreeLegendLabels, next to
|
||||
// useTreeLegendColors, and every People-facing surface reads that one copy.
|
||||
const { affinityLabelMaps } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
|
||||
|
||||
// ── COLOURS: sourced from the cap-3 research map's colour strategies ────────────────────────
|
||||
// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, via
|
||||
// useTreeLegendColors — so the legend can never drift from the map. Colour is applied AFTER the
|
||||
// rows exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched.
|
||||
const { colorFor } = useTreeLegendColors()
|
||||
|
||||
// A row inherits its ANCESTOR's colour, mirroring the map's semantic tree where one hue owns a
|
||||
// whole subtree (useAffinityBlockLegendData.js:169 assigns one colour per bloc and reuses it for
|
||||
// every descendant). We therefore track the nearest level-0 value while walking the flat row list.
|
||||
function addColors(rows, axis, famByValue) {
|
||||
if (!axis) return rows // Status: deliberately uncoloured
|
||||
let rootValue = null
|
||||
for (const row of rows) {
|
||||
if (row.level === 0) rootValue = row.value
|
||||
if (row.other) { row.swatch = null; continue } // the 'Other' bucket is not a real member
|
||||
// Religion keys its palette by FAMILY LETTER, which only the level-0 row can supply; every
|
||||
// other axis inherits the level-0 VALUE itself.
|
||||
const anchor = axis === 'religion' ? (famByValue.get(rootValue) || '') : rootValue
|
||||
row.swatch = colorFor(axis, row.facet, row.value, anchor)
|
||||
}
|
||||
return { affinity_bloc: bloc, cluster, people_group: pg }
|
||||
return rows
|
||||
}
|
||||
|
||||
// Religion family NAME → family LETTER, built from the config's famKey accessor over live rows.
|
||||
// The name is what the user sees and what the bus filters on; the letter only ever indexes the
|
||||
// map's palette, exactly as religion.js:104 does for pins.
|
||||
const religionFamKeys = computed(() => {
|
||||
const m = new Map()
|
||||
const facet = cfg.facets && cfg.facets.religion_family
|
||||
if (!facet || !facet.famKey) return m
|
||||
for (const r of (dash?.rows?.value || [])) {
|
||||
const name = facet.get(r)
|
||||
if (name && !m.has(name)) m.set(name, facet.famKey(r))
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const sections = computed(() => (cfg.facetRail || []).map((s) => {
|
||||
|
|
@ -101,7 +221,10 @@ const sections = computed(() => (cfg.facetRail || []).map((s) => {
|
|||
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 }
|
||||
rows = addColors(rows, s.axis, religionFamKeys.value)
|
||||
// Trunks are computed LAST, over the FINAL row list, so the ├/└ glyphs reflect exactly what
|
||||
// is rendered (the label swap above doesn't move rows, but any future row filtering would).
|
||||
return { id: s.id, title: s.title, axis: s.axis, rows: addTrunks(rows) }
|
||||
}))
|
||||
|
||||
// header: full-filterSet scope count (unchanged logic from the tree header).
|
||||
|
|
@ -113,8 +236,27 @@ const scopeCount = computed(() => {
|
|||
})
|
||||
|
||||
// each rendered row already carries its own facet key (data-facet/data-value).
|
||||
//
|
||||
// TWO INTENTS, TWO TARGETS: a click on the caret EXPANDS (navigate); a click anywhere else on the
|
||||
// row FILTERS (select). The caret is checked first because it sits inside the row.
|
||||
//
|
||||
// ── THE BYTE-MATCH CONTRACT ────────────────────────────────────────────────────────────────
|
||||
// `value` is read straight back off the DOM attribute that was rendered from `row.value`, and
|
||||
// `row.value` is `aggregate()`'s map key — which is the VERBATIM return of FACET_ACC[facet](row).
|
||||
// So the string handed to setFilter is byte-for-byte the accessor's own output, which is exactly
|
||||
// what every consumer re-compares against (`a(r) === filterSet[k]`). Nothing in the colour or
|
||||
// expand work above rewrites `value`; the People axis's label swap writes `label` only, and the
|
||||
// 'Other' bucket renders a null data-facet so it can never be filtered on.
|
||||
function onRowClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-facet]')
|
||||
const t = e.target
|
||||
if (!t || !t.closest) return
|
||||
const caret = t.closest('[data-expand]')
|
||||
if (caret) {
|
||||
const facet = caret.getAttribute('data-expand'); const value = caret.getAttribute('data-expand-value')
|
||||
if (facet && value != null) { e.stopPropagation(); toggleExpand(facet, value) }
|
||||
return
|
||||
}
|
||||
const el = t.closest('[data-facet]')
|
||||
if (!el || !bus) return
|
||||
const facet = el.getAttribute('data-facet'); const value = el.getAttribute('data-value')
|
||||
if (!facet || !value) return
|
||||
|
|
@ -136,34 +278,84 @@ onMounted(() => {
|
|||
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); }
|
||||
.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, #d1d4cd); }
|
||||
.rd-facets .fr-title{ font-size:12px; font-weight:700; letter-spacing:.4px; color:var(--rd-ink,#3b463d); margin-bottom:2px; }
|
||||
.rd-facets .fr-scope{ font-size:11px; color:var(--rd-muted, #6d766e); } .rd-facets .fr-scope b{ color:var(--rd-ink, #3b463d); font-size:13px; }
|
||||
/* Thin overlay scrollbar flush to the rail edge — transparent track, low-contrast thumb
|
||||
that darkens on hover (BUILD-MATERIAL-THIN-OVERLAY-SCROLLBARS). */
|
||||
.rd-facets .fr-body{ flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden;
|
||||
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
|
||||
.rd-facets .fr-body::-webkit-scrollbar{ width:8px; height:8px; }
|
||||
.rd-facets .fr-body::-webkit-scrollbar-track{ background:transparent; }
|
||||
.rd-facets .fr-body::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
|
||||
.rd-facets .fr-body::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
|
||||
.fr-sec{ border-bottom:1px solid var(--rd-line, #d1d4cd); }
|
||||
.fr-sech{ display:flex; align-items:center; gap:6px; padding: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; }
|
||||
font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted, #6d766e); }
|
||||
.fr-sech:hover{ background:var(--rd-bg, #f3f3f1); }
|
||||
.fr-sech .chev{ margin-left:auto; transition:transform .15s; font-size:10px; color:var(--dx-muted, #6d766e); }
|
||||
.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); }
|
||||
/* gap:0 so consecutive trunk spans butt flush and their 1px rules form ONE continuous line
|
||||
(an 8px flex gap would break each level's rule into a dashed ladder). The label/bar/count
|
||||
spacing that the gap used to provide is restored by margin-left on .fr-lbl below. */
|
||||
.fr-row{ position:relative; display:flex; align-items:center; gap:0; padding:3px 12px 3px 14px; cursor:pointer; font-size:12px; color:var(--dx-text, #3b463d); }
|
||||
.fr-row:hover{ background:var(--dx-surface, #f3f3f1); }
|
||||
/* Indentation is carried by the TRUNK SPANS (one 12px span per ancestor level), not by
|
||||
padding-left — so depth is drawn as a connected tree instead of implied by whitespace.
|
||||
Ported from SemanticTreeLegend's .stl-trunk-* rules: a 1px vertical rule at left:5px plus
|
||||
a 7px horizontal stub at the row midpoint. Colors are the Material Light border tone. */
|
||||
.fr-trunk{ position:relative; flex:0 0 auto; width:12px; align-self:stretch; }
|
||||
.fr-trunk-pipe::before,
|
||||
.fr-trunk-tee::before{ content:''; position:absolute; left:5px; top:0; bottom:0; width:1px;
|
||||
background:var(--rd-line,#d1d4cd); }
|
||||
.fr-trunk-elbow::before{ content:''; position:absolute; left:5px; top:0; height:50%; width:1px;
|
||||
background:var(--rd-line,#d1d4cd); }
|
||||
.fr-trunk-tee::after{ content:''; position:absolute; left:5px; top:50%; height:1px; width:7px;
|
||||
background:var(--rd-line,#d1d4cd); }
|
||||
.fr-trunk-elbow::after{ content:''; position:absolute; left:5px; top:calc(50% - 0.5px); height:1px; width:7px;
|
||||
background:var(--rd-line,#d1d4cd); }
|
||||
/* the active row's own branch darkens so the selected path reads at a glance */
|
||||
.fr-row.on .fr-trunk-pipe::before,
|
||||
.fr-row.on .fr-trunk-tee::before,
|
||||
.fr-row.on .fr-trunk-tee::after,
|
||||
.fr-row.on .fr-trunk-elbow::before,
|
||||
.fr-row.on .fr-trunk-elbow::after{ background:var(--rd-primary,#92b195); }
|
||||
.fr-row.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
|
||||
.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{ cursor:default; color:var(--dx-muted, #6d766e); }
|
||||
.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; }
|
||||
/* caret — the EXPAND hit-target, distinct from the row body's FILTER target. Fixed 12px so
|
||||
leaf rows (which render an empty .fr-caret-leaf) keep their labels on the same x as siblings
|
||||
that do have children; a ragged left edge is what makes a tree unreadable. */
|
||||
.fr-caret{ flex:0 0 auto; width:12px; text-align:center; font-size:9px; line-height:1;
|
||||
color:var(--dx-muted, #6d766e); transition:transform .15s; cursor:pointer; user-select:none; }
|
||||
.fr-caret.open{ transform:rotate(90deg); }
|
||||
.fr-caret:hover{ color:var(--rd-ink, #3b463d); }
|
||||
.fr-caret-leaf{ cursor:default; }
|
||||
/* swatch — the cap-3 map's colour for this node, so a legend row and its pins read as one thing.
|
||||
Square (not a dot) to match the charts rail's .rd-li i, which is the same idea in that panel. */
|
||||
.fr-sw{ flex:0 0 auto; width:9px; height:9px; border-radius:2px; margin-left:4px;
|
||||
box-shadow:inset 0 0 0 1px rgba(59,70,61,.18); }
|
||||
/* re-introduce the inter-column spacing the row's gap used to supply (trunks stay flush) */
|
||||
.fr-lbl{ flex:0 1 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-left:6px; }
|
||||
.fr-bar{ flex:1 1 auto; height:6px; min-width:24px; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; margin-left:8px; }
|
||||
/* magnitude fill — a QUANTITY bar, not a categorical series, so it takes the sage accent
|
||||
rather than a hue of its own (the pale blue here was the last of the drift). */
|
||||
.fr-bar i{ display:block; height:100%; background:var(--dx-accent, #92b195); 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; }
|
||||
.fr-cnt{ flex:0 0 auto; font-variant-numeric:tabular-nums; color:var(--dx-muted, #6d766e); min-width:30px; text-align:right; font-size:11px; margin-left:8px; }
|
||||
`, 'rd-facetrail')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rd-facets" data-region="facets">
|
||||
<div class="fr-head">
|
||||
<!-- "Tree Legend" — the Driver's naming ruling: the word "semantic" is dropped from the
|
||||
user-facing label (the component/docs still describe the semantic-tree MODEL).
|
||||
The four stacked trees below it are People · Place · Language · Religion, in that
|
||||
order — the four axes a researcher navigates to find who has gospel resources. -->
|
||||
<div class="fr-title">Tree Legend</div>
|
||||
<div class="fr-scope" data-scope-count><b>{{ scopeCount.toLocaleString('en-US') }}</b> groups in scope</div>
|
||||
</div>
|
||||
<div class="fr-body" ref="railEl" @click="onRowClick">
|
||||
|
|
@ -173,6 +365,20 @@ useShadowStyles(`
|
|||
<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">
|
||||
<!-- tree trunks: one span per ancestor level, same glyph vocabulary as the
|
||||
research-map's SemanticTreeLegend so the hierarchy is DRAWN, not just padded. -->
|
||||
<span v-for="(seg, i) in r.trunks" :key="'tr' + i" class="fr-trunk" :class="'fr-trunk-' + seg"></span>
|
||||
<!-- caret = EXPAND (navigate). Separate hit-target from the row body, which FILTERS.
|
||||
Leaf rows render an empty slot so every label in a tree stays left-aligned. -->
|
||||
<span v-if="r.hasChildren" class="fr-caret" :class="{ open: r.expanded }"
|
||||
:data-expand="r.facet" :data-expand-value="r.value"
|
||||
role="button" tabindex="0"
|
||||
:aria-expanded="r.expanded ? 'true' : 'false'"
|
||||
:aria-label="(r.expanded ? 'Collapse ' : 'Expand ') + r.label">▸</span>
|
||||
<span v-else class="fr-caret fr-caret-leaf" aria-hidden="true"></span>
|
||||
<!-- swatch: the cap-3 map's colour for this node. Display-only — never part of the
|
||||
filter value. Omitted for uncoloured sections and for the 'Other' bucket. -->
|
||||
<span v-if="r.swatch" class="fr-sw" :style="{ background: r.swatch }" aria-hidden="true"></span>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,29 @@
|
|||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
// Same store the facet rail reads — the source useAffinityBlockLegendData resolves People
|
||||
// codes to human names from.
|
||||
const dataStore = inject('dataStore', null)
|
||||
const searchSourceId = inject('searchSourceId', null)
|
||||
const filterSet = bus?.filterSet || {}
|
||||
const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, text: String(v), facet: cfg.facets?.[k]?.label || k })))
|
||||
|
||||
// ── People codes must not reach this chip ────────────────────────────────────────────────────
|
||||
// The People facets key by CODE (dashboard.config.js:54-57), so a chip built straight off the
|
||||
// stored value read 'Affinity Bloc:A012' — the user clicked "South Asian Peoples" and got back an
|
||||
// opaque identifier. labelFor() is the SAME projection the rail uses, so the two can never drift.
|
||||
// DISPLAY ONLY: `k` (the facet key) and the stored filterSet value are never rewritten — the bus
|
||||
// matches the accessor's output byte-for-byte, and a chip that merely looked right while the
|
||||
// table stopped responding would be a worse bug than the one being fixed.
|
||||
const { labelFor } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
|
||||
const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({
|
||||
k,
|
||||
text: String(labelFor(k, v)),
|
||||
facet: cfg.facets?.[k]?.label || k,
|
||||
})))
|
||||
|
||||
function onClick(e) {
|
||||
const el = e.target && e.target.closest && e.target.closest('[data-chip]')
|
||||
|
|
@ -20,19 +38,49 @@ function onClick(e) {
|
|||
}
|
||||
|
||||
useShadowStyles(`
|
||||
.rd-chips{ display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||||
/* BOUNDED so the chips never slide under the centred search bar (BUG-SELECTION-CHIPS-UNDER-SEARCHBAR).
|
||||
MEASURED live at 1440x900 before the fix: search x 540..900, first chip x 482..718 — ~178px of
|
||||
overlap, which is the Driver's "renders badly under the TOP search bar".
|
||||
WHY THE CHIPS MOVE AND NOT THE SEARCH: DashboardTopBar deliberately absolute-centres the search
|
||||
(left:50% + translateX(-50%)) so its centre stays fixed "regardless of the left (title/breadcrumb/
|
||||
chips) or right content widths" — a flow-based margin:auto would drift as chips grow. It also
|
||||
sits ON TOP of the row. So the search is not the thing to move.
|
||||
THE BUDGET: the search is width:360px, max-width:min(360px,42%), centred — so its left edge is at
|
||||
50% - min(180px, 21%). The chip strip starts after the brand/breadcrumb and must STOP SHORT of
|
||||
that edge. min(180px,21%) + a 14px gutter gives the max-width below; 34% is the conservative
|
||||
floor for narrow viewports where the 42% clamp binds instead of the 360px one.
|
||||
nowrap + the per-chip cap keeps the bar at 52px — the Driver has repeatedly said NEVER add a
|
||||
second header row, so growing the bar is not an option. Overflowing chips scroll horizontally
|
||||
and every truncated label keeps its full text in a title tooltip. */
|
||||
/* NO max-width here ON PURPOSE. The bound lives ONCE, on .rd-left in DashboardTopBar (which wraps
|
||||
brand + breadcrumb + chips together). I briefly had a second cap here and it was both redundant
|
||||
and a hazard: two mechanisms constraining the same axis interact, and the outer one is the only
|
||||
one that can be correct — measured, the chips' natural width (297px) is already under any sane
|
||||
cap, while the brand (248px) + breadcrumb (196px) push the chips' START past the search's left
|
||||
edge. Capping width cannot fix a start position. */
|
||||
.rd-chips{ display:flex; align-items:center; gap:6px; flex-wrap:nowrap; min-width:0;
|
||||
overflow-x:auto; overflow-y:hidden; scrollbar-width:none; }
|
||||
.rd-chips::-webkit-scrollbar{ height:0; width:0; }
|
||||
.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; }
|
||||
background:#fff; color:#1a1a2e; border:1px solid #e67e22; cursor:pointer; line-height:1.2;
|
||||
flex:0 0 auto; max-width:200px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* the label span is what actually truncates; the facet prefix and the x stay readable */
|
||||
.rd-chip .rd-chip-val{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
||||
.rd-chip .fc{ opacity:.55; font-weight:500; flex:0 0 auto; } .rd-chip i{ font-style:normal; opacity:.6; font-size:12px; flex:0 0 auto; }
|
||||
.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-chip.rd-clear{ border-color:var(--dx-line, #d1d4cd); color:var(--dx-muted, #6d766e); } .rd-chip.rd-clear:hover{ background:var(--dx-surface, #f3f3f1); }
|
||||
`, '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>
|
||||
<!-- :title carries the FULL "Facet: Value" even when the chip truncates, so a long People or
|
||||
Language label (some run 40+ chars) stays readable on hover rather than being lost to the
|
||||
ellipsis. The value lives in its own span because that is the only part that should shrink:
|
||||
the facet prefix and the ✕ affordance must stay legible at every width. -->
|
||||
<span v-for="c in chips" :key="c.k" class="rd-chip" :data-chip="c.k" data-filter-chip
|
||||
:title="c.facet + ': ' + c.text">
|
||||
<span class="fc">{{ c.facet }}:</span><span class="rd-chip-val">{{ c.text }}</span> <i>✕</i>
|
||||
</span>
|
||||
<span class="rd-chip rd-clear" data-chip="__all__">Clear all</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,21 @@
|
|||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount, watch, unref } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useMapFly } from '@map/composables/useMapFly.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
// Table selection -> map camera. The table never touches the map: it commits to the bus,
|
||||
// this composable watches bus.selectionVersion and flies ONCE per settled change.
|
||||
import { useSelectionCamera } from '../../composables/useSelectionCamera.js'
|
||||
// ── Map controls — REUSED from the shared library, not hand-rolled. MapToolbar is a
|
||||
// layout-only positioned column; each button binds :map explicitly so it can only ever
|
||||
// drive THIS panel's instance (the multi-map contract in map-controls/README.md).
|
||||
// Deliberately a MINIMAL subset (zoom in / zoom out): the dashboard cell is small and
|
||||
// the space budget is tight, so location/fullscreen/theme/share stay out.
|
||||
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
|
||||
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
|
||||
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
|
|
@ -18,11 +31,14 @@ const cfg = inject('dashboardConfig', {})
|
|||
const filterSet = bus?.filterSet || {}
|
||||
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
// shared free-text search predicate (header-1) — one source of truth with the shell + other panels.
|
||||
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
|
||||
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 }))
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }) && matchSearch(r))
|
||||
})
|
||||
// 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').
|
||||
|
|
@ -46,6 +62,28 @@ const { map, isMapReady, initializeMap, destroy } = useMapInstance({
|
|||
containerRef: mapContainer, accessToken: unref(mapboxToken) || (mapboxToken && mapboxToken.value) || '',
|
||||
center: [28, 18], zoom: 1.45, minZoom: 1,
|
||||
})
|
||||
// Shared library camera helper — one fly/fitBounds implementation for the whole studio.
|
||||
const mapFly = useMapFly({
|
||||
getMap: () => map.value,
|
||||
mapId: 'rdash-map',
|
||||
defaultCenter: [28, 18],
|
||||
defaultZoom: 1.45,
|
||||
})
|
||||
// ── Table selection -> camera (the Driver's ask: "select in the table, the map zooms there") ──
|
||||
// Frames the selection off the FULL row store, not filteredRows — matching selectedGeo()
|
||||
// below, so the camera always frames exactly the pins the selection pen draws (a row can be
|
||||
// selected and then filtered out of scope; the pen deliberately keeps showing it).
|
||||
// Gated on isMapReady so a selection made during boot can't fly a half-built camera.
|
||||
// Motion safety lives inside the composable: ONE debounced animation per settled selection,
|
||||
// never per frame, and a jump (duration 0) under prefers-reduced-motion.
|
||||
let stopSelectionCamera = null
|
||||
stopSelectionCamera = useSelectionCamera({
|
||||
bus,
|
||||
mapFly,
|
||||
getMap: () => map.value,
|
||||
getRowStore: () => dash?.rowStore?.value,
|
||||
isEnabled: () => isMapReady.value,
|
||||
})
|
||||
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
|
||||
|
|
@ -54,15 +92,37 @@ let added = false
|
|||
// General-correct (not table-specific): also covers window/rail/facet reflow. rAF-coalesced.
|
||||
let containerRO = null
|
||||
let offToggleTable = null
|
||||
let offLayoutResizing = null
|
||||
let _resizeRaf = 0
|
||||
// BUG-RESIZE-STROBE (photosensitive-seizure risk): while a section divider is being DRAGGED the
|
||||
// container changes size every frame. map.resize() makes MapLibre emit its own 'resize', which
|
||||
// useMapInstance handles by recomputing the aspect-aware zoom FLOOR from the container height and
|
||||
// calling setMinZoom/setZoom. On a vertical drag the height — and therefore the floor — moved every
|
||||
// frame, so the camera was re-snapped ~60x/sec and the whole globe re-rendered: visible strobing.
|
||||
// While `liveResize` is true we skip map.resize() entirely (the canvas is stretched by CSS, which
|
||||
// is cheap and flicker-free) and run ONE real resize on drag end.
|
||||
let liveResize = false
|
||||
function scheduleMapResize() {
|
||||
if (_resizeRaf) return
|
||||
if (liveResize || _resizeRaf) return
|
||||
_resizeRaf = requestAnimationFrame(() => {
|
||||
_resizeRaf = 0
|
||||
const m = map.value
|
||||
if (m && typeof m.resize === 'function') { try { m.resize() } catch (_) {} }
|
||||
})
|
||||
}
|
||||
// Drag start/end from useDashboardLayout. On end, do the single expensive pass that was
|
||||
// suppressed for the whole drag, plus one delayed pass for the grid/transition settle.
|
||||
function setLiveResize(on) {
|
||||
const next = !!on
|
||||
if (next === liveResize) return
|
||||
liveResize = next
|
||||
if (liveResize) {
|
||||
if (_resizeRaf) { cancelAnimationFrame(_resizeRaf); _resizeRaf = 0 }
|
||||
return
|
||||
}
|
||||
scheduleMapResize()
|
||||
setTimeout(scheduleMapResize, 180)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -202,6 +262,15 @@ onMounted(async () => {
|
|||
await initializeMap()
|
||||
const m = map.value; if (!m) return
|
||||
if (m.isStyleLoaded()) addLayers(); else m.on('load', addLayers)
|
||||
// Keep the zoom buttons' enabled state in step with the live camera. 'zoom' fires only during
|
||||
// an actual zoom; 'zoomend' catches the settle. Both just set two booleans (no camera writes).
|
||||
m.on('zoom', syncZoomLimits)
|
||||
m.on('zoomend', syncZoomLimits)
|
||||
// The minZoom FLOOR is aspect-aware, so it moves when the cell is resized: re-read the limits
|
||||
// after a resize or the zoom-out button could stay greyed once the panel grows. Read-only —
|
||||
// this handler calls no camera method, so it adds nothing to the resize work per frame.
|
||||
m.on('resize', syncZoomLimits)
|
||||
syncZoomLimits()
|
||||
// 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
|
||||
|
|
@ -210,22 +279,117 @@ onMounted(async () => {
|
|||
containerRO = new ResizeObserver(scheduleMapResize)
|
||||
containerRO.observe(mapContainer.value)
|
||||
}
|
||||
// Keep the legend clear of the attribution. Mapbox swaps between the expanded strip and
|
||||
// the compact "i" button as the map's WIDTH crosses its own breakpoint, so re-measure on
|
||||
// every panel resize, not just once. 'idle' catches the control's first paint.
|
||||
// syncAttribClearance attaches its own ResizeObserver to the attribution control the first
|
||||
// time it finds it; these events just make sure it gets a chance to run. 'idle' covers the
|
||||
// control's first paint, 'resize' covers the expanded <-> compact swap on width changes.
|
||||
syncAttribClearance()
|
||||
m.on('idle', syncAttribClearance)
|
||||
m.on('resize', syncAttribClearance)
|
||||
// Belt-and-suspenders: the RO observes the container, but when the table cell toggles
|
||||
// display:none (v-show) the grid reflow can land a frame before the observer notices, so
|
||||
// the 1fr map row grows without the canvas following → the reported gray gap. Resize
|
||||
// explicitly the moment collapse state flips (rAF now + one delayed pass for the settle).
|
||||
if (bus && typeof bus.on === 'function') {
|
||||
offToggleTable = bus.on('toggleTable', () => { scheduleMapResize(); setTimeout(scheduleMapResize, 280) })
|
||||
// suppress per-frame canvas resizes for the duration of a divider drag (anti-strobe)
|
||||
offLayoutResizing = bus.on('layoutResizing', setLiveResize)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
// Drop the pending selection fly FIRST: its debounce timer outlives the component, and a
|
||||
// camera call against a destroyed map throws. Same discipline as _clearSnap in useMapInstance.
|
||||
try { stopSelectionCamera && stopSelectionCamera() } catch (_) {} stopSelectionCamera = null
|
||||
try { offToggleTable && offToggleTable() } catch (_) {} offToggleTable = null
|
||||
try { offLayoutResizing && offLayoutResizing() } catch (_) {} offLayoutResizing = null
|
||||
if (containerRO) { try { containerRO.disconnect() } catch (_) {} containerRO = null }
|
||||
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
|
||||
if (_resizeRaf) { cancelAnimationFrame(_resizeRaf); _resizeRaf = 0 }
|
||||
// Detach the zoom-limit listeners before destroy() so they can never run on a dead map.
|
||||
try {
|
||||
const m = map.value
|
||||
if (m) {
|
||||
m.off('zoom', syncZoomLimits); m.off('zoomend', syncZoomLimits); m.off('resize', syncZoomLimits)
|
||||
m.off('idle', syncAttribClearance); m.off('resize', syncAttribClearance)
|
||||
}
|
||||
} catch (_) {}
|
||||
try { destroy && destroy() } catch (_) {}
|
||||
})
|
||||
watch([filteredRows, isMapReady], applyPaint)
|
||||
|
||||
// ── Zoom-limit state for the control buttons ──
|
||||
// useMapInstance keeps an aspect-aware minZoom FLOOR that tracks the container height, so on a
|
||||
// short dashboard cell map.zoomOut() can be silently clamped to a no-op. Mirror the live limits
|
||||
// into refs so the buttons DISABLE at the ends — a greyed button reads as "at the limit" instead
|
||||
// of a dead control, and it gives the verification a deterministic thing to assert on.
|
||||
//
|
||||
// PHOTOSENSITIVITY (BUG-RESIZE-STROBE): this is event-driven, NOT a polling/rAF loop. It listens
|
||||
// to Mapbox's own 'zoom'/'zoomend' (which only fire while a zoom is actually in flight) and writes
|
||||
// two booleans. It never calls resize(), setZoom, setMinZoom or any camera method, so it cannot
|
||||
// feed back into _onResize's debounced floor-snap — the strobe path is untouched.
|
||||
const canZoomIn = ref(true)
|
||||
const canZoomOut = ref(true)
|
||||
function syncZoomLimits() {
|
||||
const m = map.value
|
||||
if (!m) return
|
||||
// Small epsilon: Mapbox parks a clamped camera a hair off the exact limit.
|
||||
const z = m.getZoom()
|
||||
canZoomIn.value = z < m.getMaxZoom() - 0.01
|
||||
canZoomOut.value = z > m.getMinZoom() + 0.01
|
||||
}
|
||||
|
||||
// ── ATTRIBUTION CLEARANCE (licence-critical) ───────────────────────────────────
|
||||
// The legend must never cover Mapbox's "(c) Mapbox (c) OpenStreetMap Improve this map"
|
||||
// credit — visible attribution is required by the Mapbox ToS and the OSM ODbL.
|
||||
//
|
||||
// A FIXED offset is not safe here, because the thing being cleared CHANGES SIZE at a
|
||||
// breakpoint we do not control: on a narrow map Mapbox collapses the attribution into a
|
||||
// compact round "i" button that is 24px tall, TALLER than the 20px expanded strip. A
|
||||
// clearance tuned at 1440 was 2px short at 1024 and the boxes touched again.
|
||||
// So we MEASURE the real control and publish its height as --rd-attrib-clear.
|
||||
// Read-only: we only ever read the control's rect and set a variable on our own wrapper.
|
||||
const mapWrap = ref(null)
|
||||
let attribRO = null
|
||||
let attribEl = null
|
||||
function syncAttribClearance() {
|
||||
const wrap = mapWrap.value; if (!wrap) return
|
||||
let h = 0
|
||||
try {
|
||||
const root = wrap.getRootNode?.()
|
||||
const el = (root && root.querySelector)
|
||||
? root.querySelector('.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib')
|
||||
: null
|
||||
if (el) {
|
||||
// Measure the strip the control OCCUPIES measured from the PANEL'S BOTTOM EDGE —
|
||||
// not the control's own height. Mapbox gives the control a margin inside
|
||||
// .mapboxgl-ctrl-bottom-right (10px for the compact button), so height alone
|
||||
// undercounts the reserved space by exactly that margin and the legend still
|
||||
// clipped the button's top by 2px at 1024px even with the height measured correctly.
|
||||
// Panel-bottom-minus-control-top is margin-inclusive and therefore self-correcting.
|
||||
const cr = el.getBoundingClientRect()
|
||||
const wr = wrap.getBoundingClientRect()
|
||||
h = Math.max(cr.height || 0, wr.bottom - cr.top)
|
||||
// OBSERVE THE CONTROL ITSELF, not just our panel. Mapbox collapses the credit into
|
||||
// the compact "i" button AFTER first paint, and that transition changes the CONTROL's
|
||||
// height while the PANEL's size never moves — so a panel-only ResizeObserver never
|
||||
// fires and we would stay pinned to the stale expanded measurement (measured live:
|
||||
// clearance stuck at 20px while the button was really 24px, and the boxes touched).
|
||||
if (attribEl !== el && typeof ResizeObserver !== 'undefined') {
|
||||
if (attribRO) { try { attribRO.disconnect() } catch (_) {} }
|
||||
attribEl = el
|
||||
attribRO = new ResizeObserver(syncAttribClearance)
|
||||
attribRO.observe(el)
|
||||
}
|
||||
}
|
||||
} catch (_) { /* pre-paint / detached */ }
|
||||
// Floor at the expanded-strip height so we degrade to a sane gap if the control
|
||||
// is not in the DOM yet, and cap it so a freak measurement cannot eat the panel.
|
||||
const clear = Math.min(Math.max(Math.ceil(h) || 20, 20), 72)
|
||||
wrap.style.setProperty('--rd-attrib-clear', clear + 'px')
|
||||
}
|
||||
|
||||
// ── 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 (_) {} } }
|
||||
|
|
@ -250,24 +414,66 @@ 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{ position:relative; width:100%; height:100%; min-height:0; background:var(--dx-line, #d1d4cd); }
|
||||
.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);
|
||||
/* Zoom cluster. MapToolbar defaults to top:6px on the inline-end edge, which is exactly where
|
||||
the Globe/Flat pill already sits, so drop this instance below it. 34px clears the pill
|
||||
(top:8px + roughly 22px tall) with a small breathing gap.
|
||||
SPECIFICITY: MapToolbar ships .map-toolbar and .map-toolbar .mcb rules at the SAME
|
||||
specificity as a plain .rd-zoomtools override, and both stylesheets are injected into the
|
||||
same shadow root — so a tie would be resolved by injection ORDER, which is not something
|
||||
this panel controls. Doubling the class (.rd-zoomtools.rd-zoomtools) raises specificity so
|
||||
these win deterministically, without !important and without editing the shared toolbar. */
|
||||
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
|
||||
/* Economical use of space: 26px instead of the toolbar's standard 32px. The dashboard map is
|
||||
one cell among many, so the controls stay present but visually quiet. Also match the light
|
||||
dashboard surface/border tokens so the cluster reads as part of this panel. */
|
||||
.rd-zoomtools.rd-zoomtools .mcb{
|
||||
width:26px; height:26px;
|
||||
border:1px solid var(--rd-line,#d1d4cd); color:var(--dx-text,#3b463d); box-shadow:0 1px 2px rgba(0,0,0,0.10);
|
||||
}
|
||||
.rd-zoomtools.rd-zoomtools .mcb:hover:not(:disabled){ background:var(--dx-surface,#f3f3f1); box-shadow:0 1px 4px rgba(0,0,0,0.14); }
|
||||
/* MapToolbar's own mobile breakpoint re-asserts top/gap and a 36px button, so restate here. */
|
||||
@media(max-width:767px){
|
||||
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
|
||||
.rd-zoomtools.rd-zoomtools .mcb{ width:26px; height:26px; }
|
||||
}
|
||||
/* LEGEND vs ATTRIBUTION — a LICENCE constraint, not a taste call.
|
||||
Mapbox pins its own .mapboxgl-ctrl-attrib ("(c) Mapbox (c) OpenStreetMap Improve this map")
|
||||
to the map's BOTTOM-RIGHT, and both the Mapbox ToS and the OpenStreetMap ODbL require that
|
||||
credit to stay VISIBLE. This legend used to sit at right:8px/bottom:8px — the identical
|
||||
anchor — so it painted straight over the credit and the two read as one garbled strip
|
||||
(BUG-MAP-LEGEND-OVERLAPS-MAPBOX-ATTRIBUTION, live-verified at 1440x900).
|
||||
The fix RAISES THE LEGEND, and never touches the attribution: nothing here hides, shrinks
|
||||
or restyles .mapboxgl-ctrl-attrib. --rd-attrib-clear is the reserved strip along the bottom
|
||||
edge that the credit line owns; the legend's bottom edge starts above it.
|
||||
Bottom-LEFT was not available — .rd-maptag already holds that corner.
|
||||
Stays on the right so the legend sits beside the choropleth it explains. */
|
||||
.rd-mapwrap{ --rd-attrib-clear:26px; }
|
||||
.rd-legend-map{ position:absolute; right:8px; bottom:calc(var(--rd-attrib-clear,26px) + 8px); z-index:2;
|
||||
background:#ffffffe8; border:1px solid var(--rd-line, #d1d4cd);
|
||||
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-h{ font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted, #6d766e); margin-bottom:4px; }
|
||||
.rd-legend-map .lg-row{ display:flex; align-items:center; gap:6px; font-size:10px; color:var(--dx-text, #3b463d); 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="mapWrap" class="rd-mapwrap" data-sub="map" :data-projection="projection">
|
||||
<div ref="mapContainer" class="rd-mapc" data-map-canvas></div>
|
||||
<!-- Minimal control cluster: zoom in / zoom out only. Reused library components; :map binds
|
||||
each button to THIS panel's instance. Both use Mapbox's discrete zoomIn()/zoomOut() step,
|
||||
so a click is one bounded easing — never a per-frame camera loop. -->
|
||||
<MapToolbar class="rd-zoomtools" :has-geocoder="false" data-map-zoom-controls>
|
||||
<ZoomInButton :map="map" :disabled="!canZoomIn" />
|
||||
<ZoomOutButton :map="map" :disabled="!canZoomOut" />
|
||||
</MapToolbar>
|
||||
<span class="rd-proj" role="button" tabindex="0" data-proj-toggle @click="toggleProjection">
|
||||
{{ projection === 'globe' ? '🌐 Globe' : '▭ Flat' }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
<script setup>
|
||||
/**
|
||||
* DashboardResearchFilters — the DX-life research-page expand-filters dropdown set, baked into
|
||||
* the research-dashboard as the FILTERS band (the header row BELOW the topbar).
|
||||
*
|
||||
* ── HEADER STACK (card BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG) ──
|
||||
* The search bar was LIFTED UP into the DashboardTopbar (it shares the top row with Align/CSV),
|
||||
* so this band is now FILTERS-ONLY: it passes `:show-search="false"` to the shared header and
|
||||
* renders just the horizontal-scroll dropdown row. The final clean stack is therefore:
|
||||
* [topbar: title · search · Align · CSV] → [THIS filters/dropdowns row] → [KPI stats] → body.
|
||||
* The search wiring still lands on the SAME selection bus (the topbar calls bus.setSearch), so
|
||||
* every linked panel re-derives identically — only the search bar's PLACEMENT moved.
|
||||
*
|
||||
* This is the THIN ADAPTER (profile side): it renders the SHARED, parameterized
|
||||
* @map/components/search/SearchFilterHeader.vue and COUPLES it to the dashboard's selection
|
||||
* bus + in-memory row store. The header element itself is pure UI (no bus, no fetch, no
|
||||
* hardcoding) so the SAME element also drives the people-groups-grid + big-picture; the wiring
|
||||
* that makes searching/filtering change the dashboard data + map + table lives HERE.
|
||||
*
|
||||
* HOW IT COUPLES (brushing-and-linking, zero panel edits):
|
||||
* - search bar → bus.setSearch(term) → every panel's local `filtered` honors bus.searchTerm
|
||||
* via the shared makeSearchMatcher (table/map/charts/
|
||||
* facets/KPIs all re-derive → live-filter).
|
||||
* - a dropdown pick → bus.setFilter(facetKey, LABEL) — the value fed to the bus is the SAME
|
||||
* string cfg.facets[facetKey].get(row) returns (the label), so filteredRows'
|
||||
* `accessor(r) === filterSet[k]` matches (byte-match safe, spec §8 intent).
|
||||
* - clear / remove chip → bus.setFilter(facetKey, null).
|
||||
*
|
||||
* OPTIONS + COUNTS: computed from the injected dashboardData rows through the config's facet
|
||||
* accessors, with cross-filtered counts (each axis counted under the OTHER active filters) —
|
||||
* the SAME faceted-count model DX-life's UupgsList + the people-groups-grid use, so the
|
||||
* dropdown numbers agree with the facet rail.
|
||||
*
|
||||
* Which axes to show is config-driven: cfg.researchFilters.dropdowns = [facetKey, …]
|
||||
* (defaults to the classic DX-life five: region / block / country / people-group / religion,
|
||||
* mapped to this dashboard's facet keys). Nothing here is dashboard-specific beyond that map.
|
||||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import SearchFilterHeader from '@map/components/search/SearchFilterHeader.vue'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
|
||||
const bus = inject('selectionBus', null)
|
||||
const dash = inject('dashboardData', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
|
||||
const rows = computed(() => dash?.rows?.value || [])
|
||||
const filterSet = bus?.filterSet || {}
|
||||
|
||||
// facet key -> accessor (returns the LABEL the bus matches on).
|
||||
const ACC = computed(() => Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get])))
|
||||
|
||||
// Which dropdown axes to render (config-driven; DX-life default five mapped to dashboard facet keys).
|
||||
const rf = computed(() => cfg.researchFilters || {})
|
||||
const dropdownKeys = computed(() => rf.value.dropdowns || ['wagf_region', 'wagf_block', 'country_code', 'people_group', 'religion'])
|
||||
const searchPlaceholder = computed(() => rf.value.searchPlaceholder || 'Search people groups, place, language, religion…')
|
||||
|
||||
// Apply a filter-set (facetKey -> label) to a row list — reused for cross-filtered counts.
|
||||
function applyWith(fset, list) {
|
||||
const keys = Object.keys(fset)
|
||||
if (!keys.length) return list
|
||||
return list.filter((r) => keys.every((k) => { const a = ACC.value[k]; return a ? a(r) === fset[k] : true }))
|
||||
}
|
||||
|
||||
// Build one axis's options with cross-filtered counts (this axis counted under the OTHER active
|
||||
// filters — DX-life updateFilterOptionCounts). option.value === option.label === accessor output,
|
||||
// so a pick lands a byte-match-safe value on the bus.
|
||||
function optionsFor(key) {
|
||||
const acc = ACC.value[key]
|
||||
if (!acc) return []
|
||||
const others = {}
|
||||
for (const [k, v] of Object.entries(filterSet)) if (k !== key) others[k] = v
|
||||
const subset = applyWith(others, rows.value)
|
||||
const counts = new Map()
|
||||
for (const r of subset) {
|
||||
const label = acc(r)
|
||||
if (label == null || label === '') continue
|
||||
counts.set(label, (counts.get(label) || 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([label, count]) => ({ value: label, label, count }))
|
||||
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
||||
}
|
||||
|
||||
// The `filters` prop the shared header renders (ONE object per dropdown).
|
||||
const filters = computed(() => dropdownKeys.value.map((key) => ({
|
||||
name: key,
|
||||
label: cfg.facets?.[key]?.label || key,
|
||||
options: optionsFor(key),
|
||||
value: filterSet[key] || '',
|
||||
})))
|
||||
|
||||
// Chips derived from the live filterSet. For THIS component's own dropdown axes label === value
|
||||
// (optionsFor builds options straight off the accessor output), but the filterSet is SHARED — the
|
||||
// Tree Legend lands People-tier CODE filters (affinity_bloc / cluster / people_group) on the same
|
||||
// bus, and those chips rendered raw ('Affinity Bloc: A012'). labelFor() resolves them the same way
|
||||
// the filter chips do. DISPLAY ONLY: `name` is what onRemoveChip acts on and the stored value is
|
||||
// never rewritten.
|
||||
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
|
||||
const activeChips = computed(() =>
|
||||
Object.keys(filterSet).map((k) => ({ name: k, label: `${cfg.facets?.[k]?.label || k}: ${labelFor(k, filterSet[k])}` }))
|
||||
)
|
||||
|
||||
const searchValue = computed(() => bus?.searchTerm?.value || '')
|
||||
|
||||
// ── couplers ──
|
||||
function onSearch(payload) { bus?.setSearch(payload?.value ?? '') }
|
||||
function onFilterChange(detail) { bus?.setFilter(detail.name, detail.value) }
|
||||
function onFilterClear(detail) { bus?.setFilter(detail.name, null) }
|
||||
function onRemoveFilter(detail) { bus?.setFilter(detail.name, null) }
|
||||
function onClearAll() { bus?.clearFilters() }
|
||||
|
||||
useShadowStyles(`
|
||||
/* header-2 host: the research-filters band sits in the dashboard's dedicated 'filters' grid row.
|
||||
z-index high so the dropdown menus render ABOVE the stats/map/table below. */
|
||||
.rd-research-filters{ position:relative; z-index:55; height:100%; box-sizing:border-box;
|
||||
padding:8px 14px; background:var(--rd-panel,#fff); border-bottom:1px solid var(--rd-line, #d1d4cd);
|
||||
overflow:visible; }
|
||||
`, 'rd-research-filters')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-research-filters" data-region="filters" data-sub="research-filters">
|
||||
<SearchFilterHeader
|
||||
:search="searchValue"
|
||||
:filters="filters"
|
||||
:active-chips="activeChips"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:show-search="false"
|
||||
start-expanded
|
||||
@search="onSearch"
|
||||
@filter-change="onFilterChange"
|
||||
@filter-clear="onFilterClear"
|
||||
@remove-filter="onRemoveFilter"
|
||||
@clear-all="onClearAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -113,19 +113,21 @@ const bars = (cfg.search || []).map((entry) => {
|
|||
// 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 /
|
||||
// Land a group's member rows on the selectionSet in ONE commit. 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).
|
||||
// this group's members".
|
||||
//
|
||||
// Uses bus.setSelection() (the bulk replace primitive) rather than hand-mutating the
|
||||
// raw Set: selectionSet is deliberately NOT reactive, so a direct .clear()/.add() would
|
||||
// never bump selectionVersion and every dependent computed would silently go stale.
|
||||
// It is also one commit instead of two — a big PG ("Deaf", 100+ countries) repaints the
|
||||
// map once. See the performance contract in useSelectionBus.js.
|
||||
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.setSelection(members)
|
||||
bus.emit('focusRow', { slug: members[0], name })
|
||||
return true
|
||||
}
|
||||
|
|
@ -178,11 +180,11 @@ useShadowStyles(`
|
|||
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; }
|
||||
border-bottom:1px solid var(--rd-line, #d1d4cd); 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); }
|
||||
box-shadow:none!important; border:1px solid var(--rd-line, #d1d4cd); border-radius:var(--r-md,6px);
|
||||
background:var(--rd-bg, #f3f3f1); }
|
||||
.rd-searchbar .dg-standalone-geocoder{ width:100%; }
|
||||
/* lift the mapbox-gl-geocoder suggestions list above sibling dashboard panels */
|
||||
.rd-searchbar .mapboxgl-ctrl-geocoder .suggestions,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
<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.
|
||||
* One cohesive header surface holding, on ONE row:
|
||||
* title · the SHARED filter search bar · inline Breadcrumb + FilterChips · Align.
|
||||
*
|
||||
* 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).
|
||||
* CSV export is NOT here — it belongs to the people-groups table it exports, so it lives on
|
||||
* DashboardDataTable's header bar below the map (card
|
||||
* BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS).
|
||||
*
|
||||
* ── HEADER STACK (card BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG) ──
|
||||
* The primary search bar lives HERE now, in the topbar row, so the header stack reads:
|
||||
* [THIS topbar: title · search · Align] → [filters dropdowns] → [KPI stats] → body.
|
||||
* It is the SHARED SearchBar wired straight to bus.setSearch — the SAME free-text search that
|
||||
* DashboardResearchFilters used to host in the filters band — so every linked panel (table/map/
|
||||
* charts/facets/KPIs) re-derives via the shared makeSearchMatcher exactly as before; only the
|
||||
* bar's PLACEMENT moved out of the filters band and up into this row (search-ABOVE-filters).
|
||||
*
|
||||
* The old LOCAL typeahead (type-a-name → pick → bus.toggleSelect) is kept behind the
|
||||
* `showTopBarSearch` flag (default OFF) for dashboards that want a jump-to-record affordance
|
||||
* instead of a live filter — it does NOT drive the shared filter bus.
|
||||
* 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
|
||||
|
|
@ -14,9 +26,10 @@
|
|||
*/
|
||||
import { inject, computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import DashboardFilterChips from './DashboardFilterChips.vue'
|
||||
import DashboardBreadcrumb from './DashboardBreadcrumb.vue'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
|
|
@ -26,23 +39,24 @@ const cfg = inject('dashboardConfig', {})
|
|||
// 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)
|
||||
}
|
||||
// Share the WHOLE dashboard: this profile's standalone leaf page, TREE-RELATIVE
|
||||
// (<bundle>/<profile>/index.html) — ShareButton joins it to the served tree root, so the snippet
|
||||
// resolves from whatever host the folder was dropped on. Path read from the published catalog and
|
||||
// verified 200 live, not constructed by hand.
|
||||
const SHARE_EMBED_PATH = 'research-dashboard/research-dashboard/index.html'
|
||||
|
||||
// "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') }
|
||||
|
||||
// ── primary filter search (shared SearchBar → the selection bus) ──
|
||||
// The value the panels honor lives on the bus; this bar reads/writes it via bus.searchTerm /
|
||||
// bus.setSearch, so it is the SAME free-text search the whole surface re-derives against.
|
||||
const filterSearch = computed(() => bus?.searchTerm?.value || '')
|
||||
const filterSearchPlaceholder = computed(() => cfg.researchFilters?.searchPlaceholder || 'Search people groups, place, language, religion…')
|
||||
function onFilterSearch(payload) { bus?.setSearch(payload?.value ?? '') }
|
||||
|
||||
// ── live typeahead search over the in-memory row store ──
|
||||
const q = ref('')
|
||||
const open = ref(false)
|
||||
|
|
@ -82,45 +96,97 @@ function onResultsClick(e) {
|
|||
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) })
|
||||
onBeforeUnmount(() => { try { rootEl.value?.getRootNode()?.removeEventListener('click', onDocClick) } catch (_) {} })
|
||||
|
||||
useShadowStyles(`
|
||||
/* DOXA green research-map header — matches research-map.vue site header (#4e594f / light ink #F3F3F1).
|
||||
/* 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-search-w is the ONE source of truth for the centred search's width; both the search box
|
||||
itself and the chip strip's right-hand bound are derived from it, so the two can never drift
|
||||
(a hardcoded second copy is what let the chips run under the search — see .rd-chips below). */
|
||||
.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);
|
||||
--rd-search-w:min(360px,34%);
|
||||
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; }
|
||||
/* LEFT GROUP (cards BUG-SELECTED-CHIPS-UGLY-UNDER-TOP-SEARCHBAR / BUG-SELECTION-CHIPS-UNDER-SEARCHBAR).
|
||||
The search is position:absolute, so it is OUT OF FLOW — the flex row does not know the centre
|
||||
strip is occupied, and the left content grew straight under it. The search stays centred exactly
|
||||
as designed (its centre must not drift); the LEFT CONTENT is what gets bounded.
|
||||
The bound is DERIVED from --rd-search-w so the two can never drift: the reserved strip is
|
||||
--rd-search-w wide, so the left zone ends at 50% - half of it - one gutter (see below).
|
||||
nowrap + min-width:0 keeps the 52px bar SINGLE-ROW (before the fix the chips wrapped to h=50
|
||||
at 1024x768) — the Driver has said NEVER add a second header row. */
|
||||
.rd-topbar .rd-left{ display:flex; align-items:center; gap:12px; flex:0 1 auto; min-width:0;
|
||||
flex-wrap:nowrap; overflow:hidden;
|
||||
/* MEASURED RESIDUAL — why this is ONE term and not a 12px gutter plus a 14px padding add-back.
|
||||
BOTH original terms were deliberate: 12px was the gutter, and +14px added back the bar's
|
||||
padding because this box starts inside the padding box. But they NET TO +2px, and the
|
||||
measurement found exactly that overlap: chip-bar right 542 vs search left 540 at 1440,
|
||||
462 vs 460 at 1280. The reasoning was sound; the arithmetic left no gutter at all.
|
||||
The padding add-back is also not needed here: max-width resolves against this box's
|
||||
CONTAINING BLOCK, which is the topbar's CONTENT box — the 14px padding is already excluded,
|
||||
so adding it back double-counts and eats the gutter.
|
||||
One explicit term instead. 16px > 12px on purpose: it absorbs the sub-pixel rounding you get
|
||||
when two rects are rounded independently, and the narrow case where the search's percentage
|
||||
clamp binds rather than its 360px (that one measured -7px, not -2px). */
|
||||
max-width:calc(50% - (var(--rd-search-w) / 2) - 16px); }
|
||||
/* The CHIPS are the live query state, so they get first claim on the group's space. The TITLE and
|
||||
BREADCRUMB yield first and truncate with an ellipsis. Measured at 1024x768 the left zone is only
|
||||
~334px while the brand alone was 248px, so WITHOUT letting the brand shrink the chip strip
|
||||
collapsed to an unreadable 8px sliver. A chip the user cannot read is not a fix.
|
||||
min-width:0 + the nested overflow/ellipsis is what actually lets them give way: a flex item will
|
||||
not shrink past its min-content width on shrink-factor alone. */
|
||||
.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-left .rd-chips{ flex:0 1 auto; min-width:0; }
|
||||
.rd-topbar .rd-left .rd-breadcrumb{ flex:0 1 auto; min-width:0; }
|
||||
/* AFTER the .rd-brand rule above on purpose — that rule is flex:0 0 auto (never shrink) and is
|
||||
equally specific, so it would win on source order and keep the brand at its full 248px. */
|
||||
.rd-topbar .rd-left .rd-brand{ flex:0 1 auto; min-width:0; overflow:hidden; }
|
||||
.rd-topbar .rd-left .rd-brand .rd-ttl{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
||||
.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; }
|
||||
/* primary FILTER search bar (shared SearchBar → bus.setSearch), lifted into the topbar row so
|
||||
it sits ABOVE the filters-dropdowns band (card BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG).
|
||||
Compact it for the 52px bar: shrink the primitive's padding/height so the white box fits.
|
||||
CENTERED (card BUG-SEARCH-BAR-NOT-CENTERED): absolutely-positioned to the header's horizontal
|
||||
centre so its centre stays dead-on regardless of the left (title/breadcrumb/chips) or right
|
||||
(Align/CSV/toast) content widths. left:50% + translateX(-50%) is width-agnostic; a flow-based
|
||||
margin:auto would drift as the interleaved chips grow. Sits ON TOP of the row (its neighbours
|
||||
are the flow); pointer-events kept only on the box itself so it doesn't eat clicks around it. */
|
||||
.rd-topbar .rd-filter-search{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
|
||||
z-index:2; width:360px; max-width:var(--rd-search-w); min-width:150px; }
|
||||
.rd-topbar .rd-filter-search .search-box{ margin:0; color:var(--rd-header-muted,#cbc5b9); }
|
||||
.rd-topbar .rd-filter-search .search-box input{ height:32px; padding:0 10px 0 34px; font-size:var(--fs-md,12px);
|
||||
border-radius:var(--r-md,6px); border-color:#ffffff3d; }
|
||||
.rd-topbar .rd-filter-search .search-box input:focus{ border-color:var(--rd-accent,#e67e22); }
|
||||
.rd-topbar .rd-filter-search .search-icon{ left:10px; width:15px; height:15px; }
|
||||
/* narrow: return the search to normal flow (drop the absolute centring) and let it take the row
|
||||
width; the align button is already hidden here, so a centred search has no right-hand anchor. */
|
||||
.rd-shell.rd-narrow .rd-topbar .rd-filter-search{ position:static; transform:none;
|
||||
left:auto; top:auto; flex:1 1 auto; width:auto; max-width:none; }
|
||||
/* 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;
|
||||
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{ position:absolute; left:0; right:0; top:34px; z-index:60; background:#fff; border:1px solid var(--rd-line, #d1d4cd);
|
||||
border-radius:var(--r-md,6px); box-shadow:0 8px 24px #1f273322; overflow:hidden; max-height:320px; overflow-y:auto;
|
||||
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
|
||||
.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; }
|
||||
.rd-results .rd-res:hover, .rd-results .rd-res.on{ background:var(--rd-bg, #f3f3f1); }
|
||||
.rd-results .rd-res .nm{ font-weight:700; color:var(--rd-ink, #3b463d); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.rd-results .rd-res .sub{ margin-left:auto; color:var(--rd-muted, #6d766e); font-size:var(--fs-sm,11px); white-space:nowrap; flex:0 0 auto; }
|
||||
.rd-results .rd-empty{ padding:8px 10px; color:var(--rd-muted, #6d766e); font-size:var(--fs-sm,11px); }
|
||||
/* (csv button + toast styles moved to DashboardDataTable's header bar) */
|
||||
/* 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);
|
||||
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; }
|
||||
|
|
@ -129,9 +195,26 @@ useShadowStyles(`
|
|||
|
||||
<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 />
|
||||
<!-- LEFT GROUP — brand + breadcrumb + chips share ONE bounded box so they collectively stop
|
||||
short of the absolutely-centred search. A max-width on the chips ALONE cannot fix the
|
||||
overlap: measured, the chips' natural width (297px) is already under any sane cap, while
|
||||
the brand (248px) + breadcrumb (196px) push the chips' START to x=482 — past the search's
|
||||
left edge at 1280 (460) and 1024 (332). A cap limits how far a box extends from where it
|
||||
starts; it cannot move the start. Bounding the GROUP is what actually reserves the centre. -->
|
||||
<div class="rd-left">
|
||||
<span class="rd-brand"><span class="rd-ttl">{{ cfg.title || 'Research Dashboard' }}<span class="dot">.</span></span></span>
|
||||
<DashboardBreadcrumb />
|
||||
<DashboardFilterChips />
|
||||
</div>
|
||||
<div class="rd-filter-search" data-filter-search>
|
||||
<SearchBar
|
||||
:model-value="filterSearch"
|
||||
:placeholder="filterSearchPlaceholder"
|
||||
:aria-label="filterSearchPlaceholder"
|
||||
max-width="360px"
|
||||
@search="onFilterSearch"
|
||||
/>
|
||||
</div>
|
||||
<span class="rd-spacer" />
|
||||
<div v-if="showTopBarSearch" class="rd-search" data-search>
|
||||
<span class="ic">⌕</span>
|
||||
|
|
@ -152,9 +235,15 @@ useShadowStyles(`
|
|||
@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>
|
||||
<!-- CSV export MOVED to the people-groups table header (DashboardDataTable), below the map —
|
||||
card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS. It exports THAT table, so it now
|
||||
lives on that table's header bar, which stays docked even when the table is collapsed. -->
|
||||
<!-- SHARE the WHOLE dashboard (Driver 2026-09-20): "if we could actually embed this on a
|
||||
partner site from one share button... they can embed this map on all their partner
|
||||
websites." Reuses the SAME ShareButton the upg-100 MFE ships (no hand-rolled variant); the
|
||||
embed path is THIS profile's tree-relative leaf, which ShareButton joins to the served tree
|
||||
root — so the snippet it hands out works from whatever host the tree was dropped on. It
|
||||
sits where the CSV button used to be, the top-right of the dashboard. -->
|
||||
<ShareButton :embed-path="SHARE_EMBED_PATH" />
|
||||
</header>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -21,3 +21,7 @@ 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'
|
||||
// DashboardResearchFilters — the DX-life research-page search bar + expand-filters dropdown SET
|
||||
// (header-1 + header-2), a thin adapter over the shared @map/components/search/SearchFilterHeader
|
||||
// coupling it to the selection bus (search → setSearch, dropdown pick → setFilter).
|
||||
export { default as DashboardResearchFilters } from './DashboardResearchFilters.vue'
|
||||
|
|
|
|||
|
|
@ -6,8 +6,105 @@
|
|||
* 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.
|
||||
*
|
||||
* ── ALL-FIELDS EXPORT (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
|
||||
* The export must carry EVERY field the API holds, not just the 8 table columns. The limit was
|
||||
* never in this file — flatten() already walks Object.keys(record). It was UPSTREAM: the store
|
||||
* loads `?fields=<sources.json defaultFields>` (24 keys, ~2.3MB) because that is all the map /
|
||||
* table / charts render. The detail endpoint exposes 67.
|
||||
*
|
||||
* So the wide record is fetched LAZILY, here, on the first export click — not at mount:
|
||||
* - widening sources.json defaultFields would triple the payload (2.3MB -> 6.8MB) for EVERY
|
||||
* bundle that shares that file, to serve one button;
|
||||
* - the list endpoint honors an explicit `fields=` list, so one request returns all 67 keys
|
||||
* for all rows (verified live: 2,107 rows x 67 keys).
|
||||
* The result is cached for the session; if the fetch fails we still export the loaded record
|
||||
* (24 fields) rather than producing nothing — degraded, never broken.
|
||||
*/
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC }) {
|
||||
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
||||
/**
|
||||
* The full field set for the export, in a stable, human-readable order: identity first, then
|
||||
* place, people/language/religion, then the engagement + IMB research columns. Sourced from the
|
||||
* live /detail record shape (67 keys); `exportFields` in sources.json overrides it per-source.
|
||||
*/
|
||||
export const EXPORT_FIELDS = [
|
||||
// identity
|
||||
'slug', 'name', 'imb_alternate_name', 'doxa_wagf_uid', 'imb_uid', 'imb_peid', 'imb_pgid',
|
||||
'joshua_project_id', 'imb_affinity_code',
|
||||
// place
|
||||
'latitude', 'longitude', 'country_code', 'region', 'wagf_region', 'wagf_block', 'wagf_member',
|
||||
'imb_subregion', 'location_description',
|
||||
// people / language
|
||||
'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'imb_reg_of_people_3',
|
||||
'primary_language', 'imb_language_family', 'imb_language_class', 'imb_language_speakers',
|
||||
'work_in_local_language', 'imb_is_indigenous',
|
||||
// religion
|
||||
'religion', 'imb_reg_of_religion_3', 'imb_reg_of_religion_4',
|
||||
// population / believers
|
||||
'population', 'imb_population_class', 'believers_count', 'evangelical_pct',
|
||||
'imb_evangelical_level', 'imb_congregation_existing',
|
||||
// prayer + engagement
|
||||
'people_praying', 'people_committed', 'committed_duration', 'adopted_by_churches',
|
||||
'adopted_by_count', 'adopted_by_names', 'engagement_status', 'status', 'reason_engaged',
|
||||
'reason_unlisted', 'global_start_date', 'workers_long_term', 'imb_church_planting',
|
||||
'disciple_and_church_multiplication',
|
||||
// scripture + media resources
|
||||
'imb_bible_available', 'imb_bible_translation_level', 'imb_bible_year_published',
|
||||
'imb_bible_stories_available', 'imb_audio_scripture_available', 'imb_gospel_recordings_available',
|
||||
'imb_jesus_film_available', 'imb_radio_broadcast_available', 'imb_total_resources_available',
|
||||
// priority indices
|
||||
'imb_gsec', 'imb_lostness_priority_index', 'imb_strategic_priority_index',
|
||||
// media / description
|
||||
'has_photo', 'image_url', 'picture_credit', 'imb_people_description',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {(facetKey:string, value:*) => *} [labelFor] display-only projection for the provenance
|
||||
* stamp. The People facets key by CODE (dashboard.config.js:54-57), so without it the header
|
||||
* reads `affinity_bloc=A012` — a code on a user-facing surface, the same bug the filter chip had.
|
||||
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
|
||||
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
|
||||
*/
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null }) {
|
||||
// slug -> wide API record, filled by the lazy all-fields fetch below.
|
||||
let wideStore = null
|
||||
let widePromise = null
|
||||
|
||||
/**
|
||||
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
|
||||
* resolution the row store uses (shared sources.json / getApiBaseUrl), only with an explicit
|
||||
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
|
||||
*/
|
||||
function fetchWideRows() {
|
||||
if (widePromise) return widePromise
|
||||
widePromise = (async () => {
|
||||
const sourceId = dataOpts.source || sourcesConfig.activeSource
|
||||
const src = sourcesConfig?.sources?.[sourceId] || {}
|
||||
const fields = (Array.isArray(src.exportFields) && src.exportFields.length) ? src.exportFields : EXPORT_FIELDS
|
||||
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
|
||||
const lang = src.queryParams?.lang || 'en'
|
||||
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`export 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 k = r && (r.slug || r.id); if (k) map.set(k, r) }
|
||||
wideStore = map
|
||||
return map
|
||||
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
|
||||
return widePromise
|
||||
}
|
||||
|
||||
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
|
||||
function widen(r) {
|
||||
if (!wideStore) return r
|
||||
const w = wideStore.get(r && (r.slug || r.id))
|
||||
return w ? { ...r, ...w } : r
|
||||
}
|
||||
|
||||
function pickRows() {
|
||||
if (selectionSet && selectionSet.size) {
|
||||
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
|
||||
|
|
@ -22,9 +119,19 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
|
||||
return Object.keys(filterSet).length ? 'filter result' : 'all'
|
||||
}
|
||||
// {value,label} -> label; arrays -> ' | '-joined cell (picture_credit / adopted_by_names arrive as
|
||||
// arrays of objects, so a bare JSON.stringify would dump unreadable braces into the sheet).
|
||||
function cell(v) {
|
||||
if (v === null || v === undefined) return v
|
||||
if (Array.isArray(v)) return v.map((x) => cell(x)).filter((x) => x !== '' && x !== null && x !== undefined).join(' | ')
|
||||
// `link` last: a picture_credit entry can be {link,text} with a null text — fall through to the
|
||||
// URL rather than dumping raw JSON braces into the cell.
|
||||
if (typeof v === 'object') return v.label ?? v.value ?? v.text ?? v.name ?? v.link ?? JSON.stringify(v)
|
||||
return v
|
||||
}
|
||||
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 }
|
||||
for (const k of Object.keys(r)) o[k] = cell(r[k])
|
||||
return o
|
||||
}
|
||||
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
|
||||
|
|
@ -35,9 +142,19 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
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 picked = pickRows().map(widen).map(flatten)
|
||||
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
|
||||
// people → engagement → resources), then any key the API returned that the list doesn't name.
|
||||
// Union across rows, because a null-valued field can be absent on an individual record.
|
||||
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
|
||||
const cols = [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
|
||||
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
|
||||
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
|
||||
// columns below are built from the API record via flatten()/cell() and are untouched, because
|
||||
// a consumer's formulas and downstream joins key on whatever those columns already emit.
|
||||
const chips = Object.entries(filterSet)
|
||||
.map(([k, v]) => `${k}=${labelFor ? labelFor(k, v) : 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')
|
||||
|
|
@ -45,10 +162,12 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
// 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 }
|
||||
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0, fields: 0, wide: false }
|
||||
function exportCsv() {
|
||||
const csv = buildCsv()
|
||||
const rows = pickRows().length
|
||||
// header line is line 2 (line 1 is the '#' provenance comment)
|
||||
const fields = (csv.split('\r\n')[1] || '').split(',').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)
|
||||
|
|
@ -61,9 +180,28 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
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 }
|
||||
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
|
||||
try { window.__rdashExportStatus = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
|
||||
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
|
||||
}
|
||||
return { exportCsv, buildCsv, pickRows, scopeLabel, lastExport }
|
||||
|
||||
/**
|
||||
* The UI entry point: guarantee the ALL-FIELDS record before writing the file.
|
||||
*
|
||||
* Why this is async but exportCsv() stays sync — navigator.clipboard.writeText() needs the
|
||||
* TRANSIENT USER ACTIVATION of the click, and awaiting a network round-trip spends it. So the
|
||||
* first click on a cold cache resolves the wide rows and then writes: the download path always
|
||||
* works, and the clipboard path is re-attempted. Once cached (every later click) the await
|
||||
* settles in a microtask, activation survives, and clipboard works as before.
|
||||
* A failed fetch still exports the loaded 24-field record — degraded, never nothing.
|
||||
*/
|
||||
async function exportCsvAsync() {
|
||||
try { await fetchWideRows() } catch (_) { /* fall back to the loaded record */ }
|
||||
return exportCsv()
|
||||
}
|
||||
|
||||
// Let the UI warm the cache (e.g. on hover/mount) so the first real click is already wide.
|
||||
function prefetchExportFields() { try { return fetchWideRows() } catch (_) { return null } }
|
||||
|
||||
return { exportCsv, exportCsvAsync, buildCsv, pickRows, scopeLabel, lastExport, prefetchExportFields, EXPORT_FIELDS }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,29 @@ export function useDashboardLayout(options = {}) {
|
|||
clearVars()
|
||||
}
|
||||
|
||||
// ── drag-live signalling (BUG-RESIZE-STROBE) ──────────────────────────────
|
||||
// Two channels, because two different consumers need it:
|
||||
// 1. `data-rd-resizing` on the shell root — a pure-CSS hook so transitions/animations can be
|
||||
// killed for the duration of the drag (no JS needed, works inside shadow DOM).
|
||||
// 2. bus 'layoutResizing' — lets the map panel downgrade to a cheap canvas resize while the
|
||||
// drag is live, then do the one expensive re-fit on drag end.
|
||||
// Both are no-ops when absent, so this composable stays usable without a bus.
|
||||
const resizing = ref(false)
|
||||
function beginDrag() {
|
||||
if (resizing.value) return
|
||||
resizing.value = true
|
||||
const el = shellRef && shellRef.value
|
||||
if (el) el.setAttribute('data-rd-resizing', '1')
|
||||
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', true) } catch (_) {} }
|
||||
}
|
||||
function endDrag() {
|
||||
if (!resizing.value) return
|
||||
resizing.value = false
|
||||
const el = shellRef && shellRef.value
|
||||
if (el) el.removeAttribute('data-rd-resizing')
|
||||
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', false) } catch (_) {} }
|
||||
}
|
||||
|
||||
// ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ──
|
||||
function startResize(axis, e) {
|
||||
if (narrow()) return
|
||||
|
|
@ -136,7 +159,25 @@ export function useDashboardLayout(options = {}) {
|
|||
const handleEl = e.currentTarget
|
||||
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
|
||||
|
||||
function move(ev) {
|
||||
// ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ──
|
||||
// Writing the CSS vars every pointermove reflows the grid, which fires the map panel's
|
||||
// ResizeObserver → map.resize() → MapLibre's own 'resize' event → useMapInstance's
|
||||
// _onResize → setMinZoom/setZoom. Because the zoom FLOOR is derived from the container
|
||||
// HEIGHT, a vertical drag re-snapped the camera on EVERY frame — a ~60Hz full-canvas
|
||||
// re-render that reads as strobing. Flagging the drag lets the map coalesce to a single
|
||||
// cheap canvas resize while dragging and do the one expensive re-fit on drag END.
|
||||
beginDrag()
|
||||
|
||||
// Coalesce the inline-var writes to one per animation frame. pointermove can fire faster
|
||||
// than the compositor on high-polling-rate mice, so without this the grid could reflow
|
||||
// several times per painted frame — wasted work that makes the strobe worse.
|
||||
let frame = 0
|
||||
let pending = null
|
||||
function flush() {
|
||||
frame = 0
|
||||
if (!pending) return
|
||||
const ev = pending
|
||||
pending = null
|
||||
if (axis === 'facet') {
|
||||
// left facet edge → dragging right widens the facet column
|
||||
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
|
||||
|
|
@ -151,12 +192,20 @@ export function useDashboardLayout(options = {}) {
|
|||
customized.value = true
|
||||
applyVars()
|
||||
}
|
||||
function move(ev) {
|
||||
// keep only the LATEST position; the rAF does the single write for this frame
|
||||
pending = { clientX: ev.clientX, clientY: ev.clientY }
|
||||
if (!frame) frame = requestAnimationFrame(flush)
|
||||
}
|
||||
function up(ev) {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
if (frame) { cancelAnimationFrame(frame); frame = 0 }
|
||||
flush() // land the final position even if the last frame never ran
|
||||
try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {}
|
||||
persist()
|
||||
endDrag()
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
|
|
@ -173,10 +222,15 @@ export function useDashboardLayout(options = {}) {
|
|||
if (restore()) applyVars()
|
||||
if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset)
|
||||
})
|
||||
onBeforeUnmount(() => { try { offReset && offReset() } catch (_) {} })
|
||||
onBeforeUnmount(() => {
|
||||
try { offReset && offReset() } catch (_) {}
|
||||
// If the component tears down mid-drag, release the suppression so a re-mounted map
|
||||
// is never left permanently in the cheap "resizing" mode.
|
||||
try { endDrag() } catch (_) {}
|
||||
})
|
||||
|
||||
return {
|
||||
facetW, railW, tableH, customized,
|
||||
facetW, railW, tableH, customized, resizing,
|
||||
startFacetResize, startRailResize, startTableResize,
|
||||
startResize, reset, applyVars,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ 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 { useTreeLegendLabels } from './useTreeLegendLabels.js'
|
||||
import { DataSourceManager } from '@map/api/DataSourceManager.js'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
||||
|
|
@ -37,6 +38,38 @@ const mapValues = (obj, fn) =>
|
|||
// 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'))
|
||||
|
||||
/**
|
||||
* Build the free-text search accessors for a dashboard config (header-1 search bar).
|
||||
* config.searchKeys (facet keys) → those facet accessors; else the string columns' accessors.
|
||||
* ONE declaration reused by the shell (KPIs) AND every panel (table/map/charts/facets) so the
|
||||
* searchable text can never drift from what the panels show, and the substring match is not
|
||||
* copy-pasted into each panel (kills the very duplication DOXA-LIB-2 removed).
|
||||
*/
|
||||
export function buildSearchAccessors(config) {
|
||||
const FACET_ACC = mapValues(config.facets, (f) => f.get)
|
||||
const COL_ACC = Object.fromEntries((config.columns || []).map((c) => [c.key, c.get]))
|
||||
const keys = Array.isArray(config.searchKeys) ? config.searchKeys : null
|
||||
if (keys) return keys.map((k) => FACET_ACC[k] || COL_ACC[k]).filter((fn) => typeof fn === 'function')
|
||||
return (config.columns || []).filter((c) => c.type !== 'num' && typeof c.get === 'function').map((c) => c.get)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable predicate: does `row` match the bus's current free-text search term?
|
||||
* Returns true when there is no term (identical to pre-search behaviour). `accessors` is the
|
||||
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
|
||||
*/
|
||||
export function makeSearchMatcher(bus, accessors) {
|
||||
return (row) => {
|
||||
const needle = (bus?.searchTerm?.value || '').trim().toLowerCase()
|
||||
if (!needle) return true
|
||||
for (const acc of accessors) {
|
||||
const v = acc(row)
|
||||
if (v != null && String(v).toLowerCase().includes(needle)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
|
@ -149,20 +182,24 @@ export function useDashboardShell(config) {
|
|||
// 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).
|
||||
// Free-text search (header-1 search bar) — ONE shared matcher (buildSearchAccessors +
|
||||
// makeSearchMatcher), reused by every panel so the searchable text never drifts.
|
||||
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(config))
|
||||
|
||||
// linked scope: rows surviving every active facet predicate AND the text search (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 }))
|
||||
const hasText = !!(bus.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
return rows.filter((r) =>
|
||||
keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }) && matchSearch(r)
|
||||
)
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
|
|
@ -209,6 +246,14 @@ export function useDashboardShell(config) {
|
|||
// rebuilds when the rows load.
|
||||
const dataStore = { sources: { [searchSourceId]: { get features() { return geoFeatures.value } } } }
|
||||
|
||||
// selection > filter > all CSV exporter (clipboard-primary, sandbox-safe). `dataOpts` carries the
|
||||
// profile's source so the exporter's lazy ALL-FIELDS fetch hits the SAME source the rows came from.
|
||||
// `labelFor` resolves People CODES to names in the provenance COMMENT line only — constructed
|
||||
// here, below `dataStore`, because the resolver reads the normalized features it exposes.
|
||||
// Data columns are unaffected (see useCsvExport.buildCsv).
|
||||
const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId)
|
||||
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor })
|
||||
|
||||
provide('selectionBus', bus)
|
||||
provide('dashboardData', data)
|
||||
provide('dashboardConfig', config)
|
||||
|
|
|
|||
|
|
@ -10,12 +10,37 @@
|
|||
* 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.
|
||||
*
|
||||
* ── PERFORMANCE CONTRACT (BUG-TABLE-BATCH-SELECTION-FREEZES-20S) ──────────────────────────
|
||||
* selectionSet is a PLAIN Set, not `reactive(new Set())`, and `selectionVersion` is the single
|
||||
* reactive dependency that stands in for it. Read it in any computed that inspects the set:
|
||||
*
|
||||
* const isSel = computed(() => { bus.selectionVersion.value; return bus.selectionSet.has(x) })
|
||||
*
|
||||
* WHY. A `reactive(new Set())` fires Vue's ITERATE_KEY on EVERY add/delete, and the old
|
||||
* per-row `toggleSelect()` loop also fired a SYNCHRONOUS `emit('select')` per row. Selecting
|
||||
* all 2,107 people groups therefore ran 2,107 full map-panel rebuilds — each one re-scanning
|
||||
* every row and calling MapLibre `setData()` — allocating ~2.2 MILLION GeoJSON features to
|
||||
* arrive at 2,107. Measured at 5,323 ms of pure JS in node (~20 s in the browser with the real
|
||||
* map + charts + DOM). See selectMany/deselectMany/setSelection: each mutates the set fully,
|
||||
* THEN bumps the version and emits ONCE. Same work, measured at 2.9 ms — a ~1,800x speedup.
|
||||
*
|
||||
* RULE: never loop `toggleSelect()` over many slugs. Use the bulk ops. They are the only
|
||||
* reason batch selection is interactive.
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
export function createSelectionBus() {
|
||||
const filterSet = reactive({}) // { facetKey: value | value[] } — AND-stacked facets
|
||||
const selectionSet = reactive(new Set()) // Set<slug> — explicit picks
|
||||
// PLAIN Set (see the performance contract above) — pair every read with `selectionVersion`.
|
||||
const selectionSet = new Set() // Set<slug> — explicit picks
|
||||
// Bumped once per committed selection change. THE reactive handle on selectionSet: one
|
||||
// dependency to invalidate instead of a collection proxy that fires per element.
|
||||
const selectionVersion = ref(0)
|
||||
// Free-text search term (DX-life research-page search bar → header-1). Distinct from the
|
||||
// facet filterSet: it applies a substring/label match across the config's `searchKeys` in
|
||||
// useDashboardShell.filteredRows. Empty string = no text predicate (identical to before).
|
||||
const searchTerm = ref('')
|
||||
const subs = new Map() // event -> Set<cb>
|
||||
|
||||
function on(event, cb) {
|
||||
|
|
@ -38,15 +63,73 @@ export function createSelectionBus() {
|
|||
emit('filter', filterSet)
|
||||
}
|
||||
|
||||
// ── selectionSet ops (marks) ──
|
||||
function toggleSelect(slug) {
|
||||
selectionSet.has(slug) ? selectionSet.delete(slug) : selectionSet.add(slug)
|
||||
emit('select', selectionSet)
|
||||
// ── free-text search (header-1) ──
|
||||
function setSearch(term) {
|
||||
searchTerm.value = term == null ? '' : String(term)
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
function clearSelection() {
|
||||
selectionSet.clear()
|
||||
|
||||
// ── selectionSet ops (marks) ──
|
||||
// The ONE place a selection change becomes visible: bump the version, notify subscribers once.
|
||||
// Every mutator below finishes its whole batch before calling this exactly once.
|
||||
function commitSelection() {
|
||||
selectionVersion.value++
|
||||
emit('select', selectionSet)
|
||||
}
|
||||
|
||||
return { filterSet, selectionSet, on, emit, setFilter, clearFilters, toggleSelect, clearSelection }
|
||||
function toggleSelect(slug) {
|
||||
selectionSet.has(slug) ? selectionSet.delete(slug) : selectionSet.add(slug)
|
||||
commitSelection()
|
||||
}
|
||||
function clearSelection() {
|
||||
if (!selectionSet.size) return // no-op stays silent (no wasted map rebuild)
|
||||
selectionSet.clear()
|
||||
commitSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add many slugs in ONE commit. `additive:false` replaces the selection outright.
|
||||
* Returns true when something actually changed (a no-op never repaints the map).
|
||||
*/
|
||||
function selectMany(slugs, { additive = true } = {}) {
|
||||
let changed = false
|
||||
if (!additive) {
|
||||
if (selectionSet.size) { selectionSet.clear(); changed = true }
|
||||
}
|
||||
for (const s of slugs) if (!selectionSet.has(s)) { selectionSet.add(s); changed = true }
|
||||
if (changed) commitSelection()
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Remove many slugs in ONE commit. Returns true when something actually changed. */
|
||||
function deselectMany(slugs) {
|
||||
let changed = false
|
||||
for (const s of slugs) if (selectionSet.delete(s)) changed = true
|
||||
if (changed) commitSelection()
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the whole selection with `slugs` in ONE commit — the primitive the table's
|
||||
* shift+click range-select is built on (base snapshot ⊕ range, applied atomically).
|
||||
* Skips the commit when the new set is identical to the current one.
|
||||
*/
|
||||
function setSelection(slugs) {
|
||||
const next = slugs instanceof Set ? slugs : new Set(slugs)
|
||||
if (next.size === selectionSet.size) {
|
||||
let same = true
|
||||
for (const s of next) if (!selectionSet.has(s)) { same = false; break }
|
||||
if (same) return false
|
||||
}
|
||||
selectionSet.clear()
|
||||
for (const s of next) selectionSet.add(s)
|
||||
commitSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
filterSet, selectionSet, selectionVersion, searchTerm, on, emit,
|
||||
setFilter, clearFilters, setSearch,
|
||||
toggleSelect, clearSelection, selectMany, deselectMany, setSelection,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
|
||||
*
|
||||
* Subscribes to the selection bus and drives the map camera ONCE per committed selection
|
||||
* change. The table never talks to the map: it mutates `bus.selectionSet` and bumps
|
||||
* `bus.selectionVersion`; this composable watches that version. That is exactly the
|
||||
* decoupling the bus exists for — the table panel owns no camera code, and this file owns
|
||||
* no table code.
|
||||
*
|
||||
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the Driver is photosensitive) ──────────────
|
||||
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler
|
||||
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
|
||||
*
|
||||
* 1. ONE animation per committed selection change — never per frame, never per row.
|
||||
* `selectionVersion` is bumped once per BATCH (useSelectionBus's performance contract),
|
||||
* so selecting 2,107 rows emits ONE version bump, not 2,107.
|
||||
* 2. DEBOUNCED (CAMERA_DEBOUNCE_MS). Rapid shift+click range-dragging bumps the version
|
||||
* many times in a few hundred ms; only the final state moves the camera. Each new bump
|
||||
* CANCELS the pending move, so intermediate states are never animated.
|
||||
* 3. REDUCED MOTION — when the user asks for less motion we JUMP (duration 0) instead of
|
||||
* easing. Same destination, zero traversal, no optical flow across the viewport.
|
||||
* 4. NOTHING here runs in a render path. The bbox is computed inside the debounced
|
||||
* callback, i.e. at most once per settled selection, never inside a computed/watcher
|
||||
* that the map redraw depends on.
|
||||
*
|
||||
* ── COST ──────────────────────────────────────────────────────────────────────────────
|
||||
* One O(n) pass over the selected rows per settled change (n = selection size), using the
|
||||
* slug-keyed rowStore for O(1) lookups rather than re-scanning all 2,107 rows per slug.
|
||||
* Selecting everything is a single ~2,107-element numeric pass — sub-millisecond — and it
|
||||
* happens once, after the debounce, off the render path.
|
||||
*/
|
||||
import { watch } from 'vue'
|
||||
|
||||
// ── Camera policy constants ───────────────────────────────────────────────────────────
|
||||
// Debounce window. Long enough to swallow a shift+click range drag or a "select all"
|
||||
// cascade, short enough that a single checkbox click still feels immediate.
|
||||
const CAMERA_DEBOUNCE_MS = 150
|
||||
// One eased move. Matches the panel's existing fitBounds duration so the selection camera
|
||||
// and the filter camera feel like the same gesture.
|
||||
const FLY_DURATION_MS = 1200
|
||||
// Viewport padding so selected pins never sit flush against the panel edge.
|
||||
const FIT_PADDING_PX = 70
|
||||
// Ceiling for a MULTI-row fit. Without it, two rows in the same city fit at street level.
|
||||
const FIT_MAX_ZOOM = 5.5
|
||||
// A SINGLE selected row has a degenerate (zero-area) bbox — fitBounds would resolve it at
|
||||
// maxZoom, slamming the camera to street level for a people-group whose coordinate is a
|
||||
// country-scale centroid. Fly to the point at a deliberately regional zoom instead.
|
||||
const SINGLE_ROW_ZOOM = 6
|
||||
// Degenerate-bbox guard for the multi-row path: several rows can share one coordinate (or
|
||||
// sit within metres of each other), which is a zero-area bbox by another route. Below this
|
||||
// span we treat the cluster as a single point and use SINGLE_ROW_ZOOM.
|
||||
const DEGENERATE_SPAN_DEG = 0.01
|
||||
// Past this longitude span a selection effectively wraps the globe; framing it literally
|
||||
// makes the camera animate the wrong way around the antimeridian. Ease to a world overview
|
||||
// instead. Tuned to match the LEGEND_WIDE_SPAN_DEG threshold in @map/composables/useMapFly.js.
|
||||
const WIDE_SPAN_DEG = 110
|
||||
const WORLD_ZOOM = 1.45 // the panel's own initial zoom — a stable, familiar overview
|
||||
|
||||
/** True when the user has asked the OS for reduced motion. Read live (never cached): the
|
||||
* preference can be toggled mid-session, and a stale `false` would animate at them. */
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric coordinate pair for a row, or null when unusable.
|
||||
*
|
||||
* The live API returns coordinates as STRINGS ("5.68736400", verified against 2,107 live
|
||||
* records on pray.doxa.life), so every read must go through Number() — a raw string compare
|
||||
* would order "9.5" above "78.2" lexically and silently produce a wrong bbox.
|
||||
*
|
||||
* Number.isFinite (not truthiness): `Number(r.latitude) && …` rejects a legitimate 0°
|
||||
* coordinate. We accept 0 per-axis and reject only the exact 0,0 pair — the null-island
|
||||
* sentinel that a missing geocode degrades into. Flying there would show the camera an
|
||||
* empty patch of the Gulf of Guinea.
|
||||
*/
|
||||
function coordsOf(row) {
|
||||
if (!row) return null
|
||||
const lng = Number(row.longitude)
|
||||
const lat = Number(row.latitude)
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
|
||||
if (lng === 0 && lat === 0) return null // null island — a missing geocode, not a place
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null // out-of-range guard
|
||||
return [lng, lat]
|
||||
}
|
||||
|
||||
/**
|
||||
* Antimeridian-aware longitude extent.
|
||||
*
|
||||
* A Pacific selection holds longitudes near -176 and +178 (both present in the live data:
|
||||
* the full range is -176.18 .. +178.48). Naive min/max calls that a ~355° span and frames
|
||||
* the whole globe — or worse, animates the long way round. Re-projecting into [0,360) makes
|
||||
* the same set contiguous with a ~6° span. We compute both and keep the tighter one.
|
||||
*
|
||||
* Returns { minLng, maxLng, span }; maxLng may exceed 180, which Mapbox's fitBounds accepts
|
||||
* and wraps correctly.
|
||||
*/
|
||||
function lngExtent(longitudes) {
|
||||
let aMin = Infinity, aMax = -Infinity // raw [-180, 180]
|
||||
let bMin = Infinity, bMax = -Infinity // shifted [0, 360)
|
||||
for (const lng of longitudes) {
|
||||
if (lng < aMin) aMin = lng
|
||||
if (lng > aMax) aMax = lng
|
||||
const shifted = lng < 0 ? lng + 360 : lng
|
||||
if (shifted < bMin) bMin = shifted
|
||||
if (shifted > bMax) bMax = shifted
|
||||
}
|
||||
if (!Number.isFinite(aMin)) return null
|
||||
const spanA = aMax - aMin
|
||||
const spanB = bMax - bMin
|
||||
if (spanB < spanA) {
|
||||
const minRaw = bMin > 180 ? bMin - 360 : bMin
|
||||
return { minLng: minRaw, maxLng: minRaw + spanB, span: spanB }
|
||||
}
|
||||
return { minLng: aMin, maxLng: aMax, span: spanA }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding box of the selected rows.
|
||||
*
|
||||
* Resolves each slug through the slug-keyed rowStore (O(1)) instead of filtering the full
|
||||
* row array per slug, so a 2,107-row selection stays one linear pass.
|
||||
*
|
||||
* @returns {null | { kind:'point', center:[lng,lat] }
|
||||
* | { kind:'bounds', bounds:[[w,s],[e,n]], span:number }}
|
||||
* null when NOTHING is selected or no selected row has usable coordinates —
|
||||
* the caller must then leave the camera alone.
|
||||
*/
|
||||
export function selectionBounds(selectedSlugs, rowStore) {
|
||||
if (!selectedSlugs || !selectedSlugs.size || !rowStore) return null
|
||||
|
||||
const lngs = []
|
||||
let minLat = Infinity, maxLat = -Infinity
|
||||
let first = null
|
||||
let count = 0
|
||||
|
||||
for (const slug of selectedSlugs) {
|
||||
const c = coordsOf(rowStore.get(slug))
|
||||
if (!c) continue // missing/null/invalid coords — skip the row
|
||||
if (count === 0) first = c
|
||||
lngs.push(c[0])
|
||||
if (c[1] < minLat) minLat = c[1]
|
||||
if (c[1] > maxLat) maxLat = c[1]
|
||||
count++
|
||||
}
|
||||
|
||||
// Every selected row lacked coordinates (or the selection is empty) — no camera move.
|
||||
if (count === 0) return null
|
||||
if (count === 1) return { kind: 'point', center: first }
|
||||
|
||||
const ext = lngExtent(lngs)
|
||||
if (!ext) return { kind: 'point', center: first }
|
||||
|
||||
// Co-located cluster: a zero-area bbox by another route. fitBounds would hit maxZoom.
|
||||
if (ext.span < DEGENERATE_SPAN_DEG && (maxLat - minLat) < DEGENERATE_SPAN_DEG) {
|
||||
return { kind: 'point', center: [(ext.minLng + ext.maxLng) / 2, (minLat + maxLat) / 2] }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'bounds',
|
||||
bounds: [[ext.minLng, minLat], [ext.maxLng, maxLat]],
|
||||
span: ext.span,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire selection -> camera. Call once from the map panel's setup.
|
||||
*
|
||||
* @param {Object} o
|
||||
* @param {Object} o.bus - the selection bus (selectionSet + selectionVersion)
|
||||
* @param {Function} o.getMap - () => the live map instance (null before ready)
|
||||
* @param {Function} o.getRowStore - () => Map<slug, row>
|
||||
* @param {Object} o.mapFly - a useMapFly instance (shared library helper)
|
||||
* @param {Function} [o.isEnabled] - () => boolean; skip while false (e.g. map not ready)
|
||||
* @returns {Function} teardown — clears the pending timer and stops the watcher
|
||||
*/
|
||||
export function useSelectionCamera({ bus, getMap, getRowStore, mapFly, isEnabled = () => true }) {
|
||||
if (!bus || !bus.selectionVersion) return () => {}
|
||||
|
||||
let timer = null
|
||||
|
||||
function moveCamera() {
|
||||
timer = null
|
||||
const map = getMap && getMap()
|
||||
if (!map || !isEnabled()) return
|
||||
|
||||
const target = selectionBounds(bus.selectionSet, getRowStore && getRowStore())
|
||||
// EDGE CASE — selection cleared, or no selected row has coordinates: hold position.
|
||||
// Deliberately NOT a reset-to-world: an unrequested snap-back is both jarring and, for
|
||||
// a photosensitive user, motion they did not ask for. Deselecting is not a navigation.
|
||||
if (!target) return
|
||||
|
||||
// Reduced motion: same destination, zero traversal.
|
||||
const duration = prefersReducedMotion() ? 0 : FLY_DURATION_MS
|
||||
|
||||
try {
|
||||
if (target.kind === 'point') {
|
||||
// Single row (or a co-located cluster). fitBounds on a degenerate bbox resolves at
|
||||
// maxZoom; a fixed regional zoom is the honest framing for a country-scale centroid.
|
||||
mapFly.flyTo(target.center, { zoom: SINGLE_ROW_ZOOM, duration, essential: true })
|
||||
return
|
||||
}
|
||||
if (target.span > WIDE_SPAN_DEG) {
|
||||
// Globe-spanning selection (e.g. "select all"). Framing it literally makes the
|
||||
// camera swing the wrong way around the antimeridian — the visible "snap".
|
||||
mapFly.flyTo(
|
||||
[(target.bounds[0][0] + target.bounds[1][0]) / 2,
|
||||
(target.bounds[0][1] + target.bounds[1][1]) / 2],
|
||||
{ zoom: WORLD_ZOOM, duration, essential: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
mapFly.fitBounds(target.bounds, FIT_PADDING_PX, {
|
||||
maxZoom: FIT_MAX_ZOOM, duration, essential: true,
|
||||
})
|
||||
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
|
||||
}
|
||||
|
||||
// ONE watcher on ONE reactive handle. `selectionVersion` is bumped once per committed
|
||||
// batch (see useSelectionBus's performance contract), so a 2,107-row "select all" runs
|
||||
// this callback ONCE — and the debounce collapses even that into a single camera move.
|
||||
const stop = watch(() => bus.selectionVersion.value, () => {
|
||||
if (timer) clearTimeout(timer) // a newer selection supersedes the pending move
|
||||
timer = setTimeout(moveCamera, CAMERA_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
return function teardown() {
|
||||
if (timer) { clearTimeout(timer); timer = null }
|
||||
try { stop() } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* useTreeLegendColors.js — the STACKED TREE LEGEND's colour resolver (card BUILD-TREE-LEGEND-COLOURS).
|
||||
*
|
||||
* Driver's ruling: the legend's colours must come from the cap-3 research map's semantic tree, so
|
||||
* the legend can NEVER drift from the map. This module therefore declares NO palette of its own —
|
||||
* every hex is read from the SAME colour-strategy modules the map's pins paint with:
|
||||
*
|
||||
* People plugin-designs/doxa-research-map/src/colors/affinity-block.js:38 PALETTE keyed ROP1 code 'A001'
|
||||
* Place plugin-designs/doxa-research-map/src/colors/doxa-region.js:59 SLUG_PALETTE keyed slug 'middle_east'
|
||||
* (+ REGION_COLOR_ALIASES:82 keyed display label, for the label-shaped facet output)
|
||||
* Language library/colors/language-family.js:26 PALETTE keyed family name 'Indo-European'
|
||||
* Religion library/colors/religion.js:58 PALETTE keyed family letter 'C'
|
||||
*
|
||||
* ── WHY ONE ADAPTER PER AXIS, NOT ONE LOOKUP ────────────────────────────────────────────────
|
||||
* Each palette is keyed by a DIFFERENT shape, and only one of them matches what the matching
|
||||
* dashboard facet accessor emits:
|
||||
*
|
||||
* axis facet accessor returns palette wants → adapter must
|
||||
* People code(r.rop1) = 'A008' ROP1 code → direct hit
|
||||
* Place lbl(r.wagf_region) = 'Asia' slug 'asia' → label→slug, alias fallback
|
||||
* Language lbl(imb_language_family)='Mande' family display name → direct hit (may miss, see below)
|
||||
* Religion lbl(r.religion) = 'Islam - Sunni' family letter → needs the CODE, not the label
|
||||
*
|
||||
* Religion is the sharp one: `lbl()` yields the localized LABEL ("Islam - Sunni") but religion.js
|
||||
* colours off the CODE's first letter ("M"). The rail therefore colours religion leaves from the
|
||||
* tree's family node, never by re-parsing the label — see the `religionFamily` facet in
|
||||
* dashboard.config.js, which is a separate facet key precisely so one key = one projection and the
|
||||
* selection bus's byte-match contract stays intact.
|
||||
*
|
||||
* ── COLOUR IS DISPLAY-ONLY ──────────────────────────────────────────────────────────────────
|
||||
* Nothing here touches a row's `value`. Colour is a pure projection off the value the accessor
|
||||
* already produced, so adding it cannot change what `bus.setFilter(facet, value)` sends.
|
||||
*
|
||||
* ── MEASURED PALETTE GAPS (live pray-tools, 2,107 rows, 2026-09-20) ─────────────────────────
|
||||
* Verified against the live API rather than assumed. Unmapped keys fall back to NEUTRAL (never to
|
||||
* another axis's palette — a wrong-but-plausible colour is worse than an obviously absent one):
|
||||
* - ROP1 'A018' and 'A019' are LIVE but absent from affinity-block.js (which stops at A017).
|
||||
* - Language families 'Atlantic-Congo', 'Mande', 'Tai-Kadai', 'None' are live but absent from
|
||||
* language-family.js's PALETTE.
|
||||
* These are upstream data-vs-palette gaps, reported not papered over. Fixing them means adding
|
||||
* entries to the MAP's strategy files (so pins and legend move together) — not inventing a hex here.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { getColorStrategy, COLOR_MODES } from '@map/colors/_registry.js'
|
||||
import { PALETTE as RELIGION_PALETTE } from '@map/colors/religion.js'
|
||||
import { PALETTE as LANGFAM_PALETTE } from '@map/colors/language-family.js'
|
||||
|
||||
// A deliberately NEUTRAL unmapped tone, not a palette colour: an uncoloured node must read as
|
||||
// "this axis has no colour for this key", never as a confident member of some other family.
|
||||
// Matches the Material Light border token so it disappears into the rail rather than shouting.
|
||||
export const NEUTRAL = '#c4c8c0'
|
||||
|
||||
/**
|
||||
* Read a registered strategy's palette, GUARDED on propertyKey.
|
||||
*
|
||||
* getColorStrategy() silently falls back to language-family for an unregistered mode, so an
|
||||
* unguarded read would paint People rows with LANGUAGE colours and look plausible while being
|
||||
* entirely wrong. This is the same guard useAffinityBlockLegendData.js:57 applies, and for the same
|
||||
* reason. Read lazily (at call time) so bundle-local registerStrategies() has already run.
|
||||
*/
|
||||
function guardedPalette(mode, propertyKey) {
|
||||
const strat = getColorStrategy(mode)
|
||||
return (strat && strat.propertyKey === propertyKey && strat.palette) ? strat.palette : null
|
||||
}
|
||||
|
||||
// ── Place: label → slug ─────────────────────────────────────────────────────────────────────
|
||||
// doxa-region.js:52 documents that wagf_region's LABEL is localized per `lang` while the slug is
|
||||
// stable, so the slug palette is the authoritative one. The Place facet emits the label, so derive
|
||||
// the slug the same way the API does (lowercase, spaces → underscores) and keep the label-keyed
|
||||
// REGION_COLOR_ALIASES as the fallback. Verified: all 7 live region labels resolve by this path.
|
||||
const toSlug = (label) => String(label || '').trim().toLowerCase().replace(/\s+/g, '_')
|
||||
|
||||
/**
|
||||
* Resolve one facet value → a hex colour, per axis.
|
||||
*
|
||||
* @param {string} axis one of 'people' | 'place' | 'language' | 'religion'
|
||||
* @param {string} facet the dashboard facet key the row was aggregated on
|
||||
* @param {string} value the facet accessor's EXACT output (also the bus filter value)
|
||||
* @param {string} [famLetter] religion family letter, supplied by the tree (never re-parsed here)
|
||||
* @returns {string} hex colour, or NEUTRAL when this axis has no colour for the key
|
||||
*/
|
||||
export function useTreeLegendColors() {
|
||||
// Both Place maps come off the ONE registered doxa-region strategy — palettes are never copied.
|
||||
const placeMaps = computed(() => {
|
||||
const strat = getColorStrategy(COLOR_MODES.DOXA_REGION)
|
||||
const ok = strat && strat.propertyKey === 'doxaRegion'
|
||||
return {
|
||||
slug: (ok && strat.slugColors) || null,
|
||||
alias: (ok && strat.aliases) || null,
|
||||
}
|
||||
})
|
||||
|
||||
function colorFor(axis, facet, value, famLetter) {
|
||||
if (value == null || value === '') return NEUTRAL
|
||||
|
||||
if (axis === 'people') {
|
||||
// Every People tier (bloc → cluster → people group) inherits its BLOC's colour — exactly
|
||||
// what the map's semantic tree does (useAffinityBlockLegendData.js:169 assigns one `color`
|
||||
// per bloc and reuses it for cluster/pg/pgic children). The tree passes the bloc code down
|
||||
// as `famLetter`, so a cluster row is coloured by its ancestor, not by its own code.
|
||||
const palette = guardedPalette(COLOR_MODES.AFFINITY_BLOCK, 'affinityBlock')
|
||||
if (!palette) return NEUTRAL
|
||||
return palette[famLetter || value] || NEUTRAL
|
||||
}
|
||||
|
||||
if (axis === 'place') {
|
||||
// Only the top tier (region) is coloured; bloc/country inherit via `famLetter` (the region
|
||||
// label of the ancestor), mirroring the map where a region's hue owns its whole subtree.
|
||||
const { slug, alias } = placeMaps.value
|
||||
const key = famLetter || value
|
||||
if (slug) { const hit = slug[toSlug(key)]; if (hit) return hit }
|
||||
if (alias) { const hit = alias[key]; if (hit) return hit }
|
||||
return NEUTRAL
|
||||
}
|
||||
|
||||
if (axis === 'language') {
|
||||
// language_family rows hit the palette directly; primary_language rows inherit their
|
||||
// family's colour through `famLetter` (the ancestor family name).
|
||||
return LANGFAM_PALETTE[famLetter || value] || NEUTRAL
|
||||
}
|
||||
|
||||
if (axis === 'religion') {
|
||||
// Keyed by the FAMILY LETTER only — the tree supplies it from religion.value's first char.
|
||||
// Never derived from the display label, which is localized and would not match.
|
||||
return RELIGION_PALETTE[famLetter] || NEUTRAL
|
||||
}
|
||||
|
||||
return NEUTRAL
|
||||
}
|
||||
|
||||
return { colorFor, NEUTRAL }
|
||||
}
|
||||
|
||||
export default useTreeLegendColors
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* useTreeLegendLabels.js — the People axis's CODE → HUMAN NAME resolver
|
||||
* (card BUG-FILTER-CHIP-SHOWS-RAW-CODE-NOT-HUMAN-NAME).
|
||||
*
|
||||
* The People facets (affinity_bloc / cluster / people_group) key by CODE on purpose — see
|
||||
* dashboard.config.js:54-57 — so the rail can cross-reference the research map's semantic tree.
|
||||
* The price of that choice is that EVERY user-facing surface has to project the code back to a
|
||||
* name, and "codes must never reach a user-facing menu". The facet rail did that projection
|
||||
* inline, so it was correct there and nowhere else: the filter chip bar rendered the raw code
|
||||
* ('Affinity Bloc:A012' instead of 'South Asian Peoples'), live-verified 2026-09-20.
|
||||
*
|
||||
* This module is that projection, extracted ONCE, exactly as useTreeLegendColors is the colour
|
||||
* projection extracted once. Both surfaces now read the same maps, built by running the SAME
|
||||
* useAffinityBlockLegendData the research map's semantic tree uses over the shell's normalized
|
||||
* features — so a bloc/cluster/PG name can never drift between the map, the rail and the chips.
|
||||
*
|
||||
* ── LABEL IS DISPLAY-ONLY ────────────────────────────────────────────────────────────────────
|
||||
* Nothing here touches a filter VALUE. The selection bus matches a stored filter against the
|
||||
* facet accessor's exact output byte-for-byte, so rewriting the value would leave the chip
|
||||
* looking right while the table silently stopped responding. `labelFor` is a pure read: it takes
|
||||
* a value and returns a string to PRINT, and falls back to the value itself when unresolvable
|
||||
* (an honest code beats a blank chip).
|
||||
*
|
||||
* ── WHICH KEYS ARE PEOPLE KEYS IS DECLARED, NOT HARD-CODED ──────────────────────────────────
|
||||
* The set is derived from config.facetRail's `tree:'affinity'` section and its `drill` list — the
|
||||
* same declaration the rail already switches on. Adding a 4th People tier to that drill therefore
|
||||
* fixes the chips too, with no second list to keep in sync.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useAffinityBlockLegendData } from '@map/composables/legends/useAffinityBlockLegendData.js'
|
||||
|
||||
/**
|
||||
* @param {object} cfg the injected dashboardConfig (read for facetRail)
|
||||
* @param {object} dataStore the injected DataSourceManager store
|
||||
* @param {string} searchSourceId the injected active source id
|
||||
*/
|
||||
export function useTreeLegendLabels(cfg, dataStore, searchSourceId) {
|
||||
// DataSourceManager-normalized features — the shape useAffinityBlockLegendData resolves labels
|
||||
// from (affinityBlockLabel / clusterLabel / peopleGroupLabel).
|
||||
const normalizedFeatures = computed(() => {
|
||||
const src = dataStore && searchSourceId && dataStore.sources && dataStore.sources[searchSourceId]
|
||||
return (src && src.features) || []
|
||||
})
|
||||
|
||||
const { aggregated: affinityAgg } = useAffinityBlockLegendData(normalizedFeatures)
|
||||
|
||||
// The People facet keys, in tier order, read off the declarative rail config.
|
||||
const treeFacetKeys = computed(() => {
|
||||
const section = (cfg && cfg.facetRail || []).find((s) => s.tree === 'affinity')
|
||||
return (section && section.drill) || []
|
||||
})
|
||||
|
||||
// code → label lookups keyed to the People facet keys, walked from the composable's aggregate
|
||||
// tree. Tier order follows treeFacetKeys, so the maps line up with the config's drill.
|
||||
const affinityLabelMaps = computed(() => {
|
||||
const [blocKey, clusterKey, pgKey] = treeFacetKeys.value
|
||||
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) }
|
||||
}
|
||||
}
|
||||
const out = {}
|
||||
if (blocKey) out[blocKey] = bloc
|
||||
if (clusterKey) out[clusterKey] = cluster
|
||||
if (pgKey) out[pgKey] = pg
|
||||
return out
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve a facet value to what a human should READ. Non-People facets already carry labels
|
||||
* (their accessors run lbl()), so they pass straight through untouched.
|
||||
* @returns {string} the human name, or the original value when it cannot be resolved.
|
||||
*/
|
||||
function labelFor(facetKey, value) {
|
||||
const map = affinityLabelMaps.value[facetKey]
|
||||
if (!map) return value
|
||||
return map.get(value) || value
|
||||
}
|
||||
|
||||
return { normalizedFeatures, affinityAgg, affinityLabelMaps, treeFacetKeys, labelFor }
|
||||
}
|
||||
|
||||
export default useTreeLegendLabels
|
||||
35
plugin-designs/stack-demo/index.html
Normal file
35
plugin-designs/stack-demo/index.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · stack-demo staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<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', system-ui, sans-serif; }
|
||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 28px 20px 48px; }
|
||||
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; max-width: 820px; }
|
||||
.sub code { color: #c4b5fd; }
|
||||
/* The whole point: stack-demo grows to its natural height and the PAGE scrolls through it. */
|
||||
.host { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
|
||||
stack-demo { display: block; width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>stack-demo · staging</h1>
|
||||
<p class="sub">Capability 4, STACK variant — the shared <code>StackLayout</code> renders two
|
||||
elements (<code><research-dashboard-map></code> then <code><people-groups-grid></code>)
|
||||
<strong>on top of each other</strong>, each at its natural height, so you
|
||||
<strong>scroll down</strong> through both on one page (the doxa-life / nuxtinator page-flow model).
|
||||
Embed <code><stack-demo></code> anywhere and the host page provides the scroll.</p>
|
||||
<div class="host">
|
||||
<stack-demo></stack-demo>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
plugin-designs/stack-demo/index.js
Normal file
28
plugin-designs/stack-demo/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* stack-demo / index.js — Bundle entry (Capability-4 nested-elements-mfe, STACK variant).
|
||||
*
|
||||
* ONE folder -> ONE bundle -> ONE headline element:
|
||||
* <stack-demo> — the COMPOSITE. Uses the shared StackLayout to render two elements
|
||||
* (<research-dashboard-map> then <people-groups-grid>) STACKED top-to-bottom,
|
||||
* each at natural height, so the host page scrolls through both.
|
||||
*
|
||||
* The two nested elements are NOT bundled in here — StackLayout injects their bundles at runtime
|
||||
* (via each section's `src`, deduped), then mounts the element. This keeps stack-demo tiny and lets
|
||||
* it compose ANY already-published elements by tag + src. Registers <stack-demo> first so
|
||||
* discover.mjs names the bundle after it.
|
||||
*
|
||||
* Usage — direct-JS (method 3):
|
||||
* <script src=".../stack-demo/stack-demo.js"></script>
|
||||
* <stack-demo></stack-demo>
|
||||
* Or iframe its leaf page and let the host page scroll.
|
||||
*/
|
||||
import { defineCustomElement } from 'vue'
|
||||
import StackDemo from './stack-demo.vue'
|
||||
|
||||
const StackDemoElement = defineCustomElement(StackDemo)
|
||||
|
||||
if (typeof customElements !== 'undefined') {
|
||||
if (!customElements.get('stack-demo')) customElements.define('stack-demo', StackDemoElement)
|
||||
}
|
||||
|
||||
export default StackDemoElement
|
||||
4
plugin-designs/stack-demo/stack-demo.instances.json
Normal file
4
plugin-designs/stack-demo/stack-demo.instances.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"_comment": "One default instance of the stacked pair.",
|
||||
"instances": [ { "id": "pair", "label": "Dashboard + Grid stacked", "profileConfig": {} } ]
|
||||
}
|
||||
6
plugin-designs/stack-demo/stack-demo.meta.json
Normal file
6
plugin-designs/stack-demo/stack-demo.meta.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"_comment": "Capability-4 composite that STACKS two elements top-to-bottom via the shared StackLayout (library/layouts/StackLayout.vue). Unlike big-picture (which couples header<->grid via props), this simply GROUPS two independent elements vertically so you can embed them together and SCROLL through them. Demonstrates the scroll-stack layout primitive.",
|
||||
"type": "nested-elements-mfe",
|
||||
"composes": ["research-dashboard-map", "people-groups-grid"],
|
||||
"label": "Stack Demo (two elements stacked to scroll — StackLayout)"
|
||||
}
|
||||
51
plugin-designs/stack-demo/stack-demo.vue
Normal file
51
plugin-designs/stack-demo/stack-demo.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script setup>
|
||||
/**
|
||||
* stack-demo.vue — Capability-4 composite demonstrating the shared StackLayout (scroll-stack).
|
||||
*
|
||||
* @element stack-demo
|
||||
* @description Groups TWO independent elements TOP-TO-BOTTOM so you can embed the pair on a page and
|
||||
* SCROLL down through all of it (the doxa-life / nuxtinator page-flow model), instead of switching
|
||||
* between them with tabs. Section 1 = the rich <research-dashboard-map> (facets + map + charts +
|
||||
* table); section 2 = the cap-2 <people-groups-grid> (live pray.doxa.life). Each renders at its
|
||||
* NATURAL height; the host page scrolls.
|
||||
*
|
||||
* This is the STACK counterpart to big-picture (which TABS/couples). Both reuse the shared library
|
||||
* layouts in library/layouts/. StackLayout mounts each section's `element` eagerly (all visible to
|
||||
* scroll) via the same element/src/attrs contract VerticalTabsLayout uses.
|
||||
*/
|
||||
import StackLayout from '@map/layouts/StackLayout.vue'
|
||||
|
||||
// The two elements to stack. `src` is TREE-RELATIVE at build; the leaf pages resolve it against the
|
||||
// bundle tree root. Both elements ship their own bundles in the same catalog.
|
||||
const sections = [
|
||||
// src is resolved by the browser relative to THIS leaf page, which is served two levels deep
|
||||
// (doxa-maps/stack-demo/stack-demo/index.html) — so go UP TWO to reach a sibling bundle's tree
|
||||
// (doxa-maps/<bundle>/<bundle>.js). (Off-by-one here = a 404 + an empty section, caught silently.)
|
||||
{
|
||||
key: 'dashboard',
|
||||
title: 'Research Dashboard',
|
||||
sub: 'facets · map · charts · table (live)',
|
||||
element: 'research-dashboard-map',
|
||||
src: '../../research-dashboard/research-dashboard.js'
|
||||
},
|
||||
{
|
||||
key: 'grid',
|
||||
title: 'People Groups',
|
||||
sub: 'the full searchable list (live)',
|
||||
element: 'people-groups-grid',
|
||||
src: '../../people-groups-grid/people-groups-grid.js'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StackLayout title="Stacked view" :sections="sections">
|
||||
<template #meta>scroll ↓</template>
|
||||
</StackLayout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* stack-demo is a thin composition: the shell + its scroll behaviour live in the shared StackLayout
|
||||
(which self-injects its own shadow CSS). Nothing to add here beyond making the host fill width. */
|
||||
:host { display:block; width:100%; }
|
||||
</style>
|
||||
Loading…
Reference in a new issue