Merge main into bundler-1/work
Main moved 20+ commits while the regions work was on its own branch. Every
conflict was a GENERATED file — manifest.json, the root page, and the
regions-dashboard bundle's .js and leaf pages. Zero source conflicts: all six
of my source files (the two regions .vue, the new map child, fix-sri.mjs,
sri-consistency-check.mjs, package.json) auto-merged.
Generated files are not hand-merged. They were resolved to main's side and then
REGENERATED from the merged source — BUNDLE=regions-dashboard vite build, then
bun run tree, then bun run fix:sri. `bun run tree` rewrote every bundle's pages
and produced byte-identical output to main's committed ones for all twenty
other bundles, so the only files this commit changes beyond the merge are the
six that had to be rebuilt. That agreement is also evidence main's committed
tree matches main's source.
The pinHint:false regression I saw before is gone: main's 566ad46 committed the
two metas that were previously only on disk, which was the root cause — a tree
regeneration now preserves them instead of dropping them.
Re-verified against the merged library, which is the part that mattered: main
changed useMapInstance.js, StackLayout.vue and ViewSelect.vue, all of which the
regions element sits on. All three suites still pass — the map draws 934 pins /
51 outline features for a region and 61 / 5 for a country, the pin mode still
follows the subject, the country page still renders CountryDetail's shape, and
every width still matches DX-life's container formula at six viewports.
23 of 25 gates green. The two that fail are pre-existing and not in this diff:
check:library-index is an mtime artifact (regenerating the index produces
byte-identical content), and check:backlog-status reports 6 of 546 non-enum
statuses in the shared _qa-cycle board, which lives outside every repo.
This commit is contained in:
commit
a334e8ebff
86 changed files with 1994 additions and 602 deletions
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bash .claude/statusline-plugin.sh",
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
100
.claude/statusline-plugin.sh
Executable file
100
.claude/statusline-plugin.sh
Executable file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env bash
|
||||
# statusline-plugin.sh — "which plugin is this agent working on?", in one terminal line.
|
||||
#
|
||||
# WHY: several builder agents run at once against this repo, each in its own worktree, and from the
|
||||
# outside every one of them looks like a Claude terminal printing tool calls. Asking "which plugin
|
||||
# are you on?" cost a round trip every time. This answers it continuously, without being asked.
|
||||
#
|
||||
# Renders: doxa-maps · bundler-2/work · ▶ research-map-plus-grid (cap-4)
|
||||
#
|
||||
# ★ THE TIER COMES FROM THE MANIFEST, NOT FROM A TABLE RETYPED HERE.
|
||||
# plugin-studio.vue derives every badge with PT/capFor off the manifest's profile `type`, so
|
||||
# retyping a profile there moves its badge with zero edits. A second hardcoded copy of that
|
||||
# mapping in this script would be a fact stored twice with nothing forcing agreement — the exact
|
||||
# shape check:capability-citations exists to catch (the README once captioned a bundle as cap-1
|
||||
# while the manifest said cap-2). So this reads manifest.json and applies 5 - PT[type], the one
|
||||
# formula, to the one source.
|
||||
#
|
||||
# ★ FAIL-SOFT EVERYWHERE, ON PURPOSE. A status line runs on every render. If it errors, is slow, or
|
||||
# prints a stack trace, it makes the terminal worse than having none — so every step degrades to a
|
||||
# quieter line instead of failing: no git → no branch, no manifest → no tier, nothing edited → "idle".
|
||||
# It never exits non-zero.
|
||||
#
|
||||
# Wired up by .claude/settings.json (statusLine). No absolute paths: check:no-abs-paths requires a
|
||||
# clone to work on any machine, and a status line is exactly the sort of dotfile that quietly
|
||||
# hardcodes someone's home directory.
|
||||
set -uo pipefail
|
||||
|
||||
IN=$(cat 2>/dev/null || true)
|
||||
# Claude Code hands the hook a JSON blob; workspace.current_dir is the directory the session is in.
|
||||
# Parsed with sed rather than a JSON tool because this runs on every render and must stay cheap.
|
||||
DIR=$(printf '%s' "$IN" | sed -n 's/.*"current_dir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
[ -z "$DIR" ] && DIR=$(printf '%s' "$IN" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
[ -z "$DIR" ] && DIR="$PWD"
|
||||
cd "$DIR" 2>/dev/null || true
|
||||
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || ROOT=""
|
||||
if [ -n "$ROOT" ]; then cd "$ROOT" 2>/dev/null || true; fi
|
||||
# The repo NAME, not the checkout's directory name: every builder works in its own worktree
|
||||
# (wt-bundler-2, wt-bundler-1, ...), and printing the folder would label each agent with a name
|
||||
# nobody recognises instead of the project they are all in. The remote is the stable identity.
|
||||
REPO=$(basename -s .git "$(git config --get remote.origin.url 2>/dev/null)" 2>/dev/null)
|
||||
[ -z "$REPO" ] && REPO=$(basename "${ROOT:-$PWD}")
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||
|
||||
# WHAT IS BEING WORKED ON — uncommitted work first, because that is what "working on" means.
|
||||
# Ordered by mtime, not by git's alphabetical listing: with several bundles dirty, the one touched
|
||||
# most recently is the honest answer and the alphabetical first is an arbitrary one.
|
||||
DIRTY=$(git status --porcelain 2>/dev/null | sed 's/^...//' | sed 's/^.* -> //')
|
||||
BUNDLE=$(printf '%s\n' "$DIRTY" | grep '^plugin-designs/' 2>/dev/null | xargs -r ls -td 2>/dev/null | head -1 | cut -d/ -f2)
|
||||
|
||||
SCOPE=""
|
||||
if [ -z "$BUNDLE" ]; then
|
||||
# A library/ edit is not one plugin's business — it compiles into every bundle that imports it,
|
||||
# which is the blast radius AGENT-BRIEF makes you name before changing anything in there. Say so
|
||||
# rather than naming whichever bundle happens to be dirty for an unrelated reason.
|
||||
if printf '%s\n' "$DIRTY" | grep -q '^library/'; then
|
||||
SCOPE="library (reaches every bundle)"
|
||||
elif printf '%s\n' "$DIRTY" | grep -q '^internal/'; then
|
||||
SCOPE="internal (build + gates)"
|
||||
else
|
||||
# Nothing uncommitted: fall back to the last commit that touched a plugin, so a line still says
|
||||
# something true right after a commit instead of going blank at the moment work lands.
|
||||
# -m makes a MERGE commit list its files: without it a merge reports nothing changed, so the
|
||||
# line read "idle" immediately after landing work — the least accurate moment to say so. Look
|
||||
# back a few commits too, since the tip is often a merge or a build-output commit.
|
||||
BUNDLE=$(git log -6 --name-only --format= -m 2>/dev/null | grep -m1 '^plugin-designs/' | cut -d/ -f2)
|
||||
[ -n "$BUNDLE" ] && SCOPE="" || SCOPE="idle"
|
||||
fi
|
||||
fi
|
||||
|
||||
TIER=""
|
||||
if [ -n "$BUNDLE" ] && [ -f public/doxa-maps/manifest.json ]; then
|
||||
TIER=$(python3 - "$BUNDLE" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
# The studio's own table (plugin-studio.vue:293) — tier = 5 - PT[type]; unknown types are cap-1.
|
||||
PT = {'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'parameterized': 3, 'single': 4}
|
||||
try:
|
||||
m = json.load(open('public/doxa-maps/manifest.json'))
|
||||
want = sys.argv[1]
|
||||
caps = [5 - PT[p.get('type')] if p.get('type') in PT else 1
|
||||
for b in m.get('bundles', []) if b.get('bundle') == want
|
||||
for p in b.get('profiles', [])]
|
||||
# A bundle may ship several profiles at different tiers; the HEADLINE capability is the highest
|
||||
# one it demonstrates, which is how the studio's left rail groups it.
|
||||
if caps: print(max(caps))
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
)
|
||||
fi
|
||||
|
||||
OUT="$REPO"
|
||||
[ -n "$BRANCH" ] && OUT="$OUT · $BRANCH"
|
||||
if [ -n "$BUNDLE" ]; then
|
||||
if [ -n "$TIER" ]; then OUT="$OUT · ▶ $BUNDLE (cap-$TIER)"; else OUT="$OUT · ▶ $BUNDLE"; fi
|
||||
elif [ -n "$SCOPE" ]; then
|
||||
OUT="$OUT · ▶ $SCOPE"
|
||||
fi
|
||||
printf '%s\n' "$OUT"
|
||||
exit 0
|
||||
|
|
@ -21,11 +21,29 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
// START of every build, so two builds racing give opposite answers seconds apart). Use
|
||||
// BUILD_LOCK_WAIT=600 to QUEUE rather than collide. Note the lock serializes but does NOT
|
||||
// dedupe: N queued builds = N full rebuilds.
|
||||
// ★ THE PIPELINE RUNS IN PHASES, NOT AS ONE `&&` CHAIN (2026-09-23). Measured on the build
|
||||
// box: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were
|
||||
// queueing behind check:mjs-parse (26.9s) and check:parse (18.7s), not working. The 22 vite
|
||||
// builds cost 129.0s and were never ordered at all: vite.config.js gives each bundle its own
|
||||
// outDir with emptyOutDir:false, so they only ever needed to not overwrite each other.
|
||||
// Now: gates:pre ‖ → library-index → clean → token → build:bundles ‖ → tree → gates:post ‖.
|
||||
// MEASURED end-to-end (both green, clean+token excluded as they are common to both):
|
||||
// 165.7s → 65.3s mean of two rounds each. Concurrency: GATES_CONCURRENCY (default 6),
|
||||
// BUILD_CONCURRENCY (default 4 — 6 buys only 3-9%, and ambient load on this shared box moves
|
||||
// the same setting 40s→55s, so being a good neighbour costs nothing measurable).
|
||||
// ★ NO GATE WAS REMOVED, WEAKENED, SAMPLED, OR MADE CONDITIONAL ON WHAT CHANGED. The step set
|
||||
// is byte-identical (31 in, 31 out, asserted at edit time); only the start times moved. The
|
||||
// `&&` BETWEEN phases is load-bearing and stays: a source gate still blocks the build, and
|
||||
// the build still blocks every gate that reads its output. Within a phase the runner reports
|
||||
// ALL failures instead of stopping at the first, so three broken gates cost one cycle, not three.
|
||||
// If you edit package.json, re-transcribe this block — a doc that QUOTES a config is
|
||||
// claiming to be ground truth, and this one had silently stopped being it.
|
||||
"dev": "bun x vite",
|
||||
"build": "bash internal/build-lock.sh",
|
||||
"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production
|
||||
"cap:sync": "./node_modules/.bin/cap sync android",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/ResearchMapSideMenu.vue'`
|
||||
- **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application.
|
||||
`import … from '@map/components/SideMenuDrawer.vue'`
|
||||
- **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
|
||||
`import … from '@map/components/ViewSelect.vue'`
|
||||
- **BarChart** — BarChart.vue — THE SHARED HORIZONTAL BAR CHART (hand-rolled SVG, zero dependencies).
|
||||
`import … from '@map/components/charts/BarChart.vue'`
|
||||
- **PinHint** — PinHint.vue — the conversion coach-mark (bottom-center of any map).
|
||||
|
|
|
|||
166
internal/run-parallel.mjs
Normal file
166
internal/run-parallel.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-parallel.mjs — runs a SET OF INDEPENDENT verification steps concurrently instead of
|
||||
* one-at-a-time, and grades the set by exit code.
|
||||
*
|
||||
* WHY (measured 2026-09-23, on this box, `bun run` per step, load avg ~20):
|
||||
* The 29 gates in build:unlocked cost 84.5s wall when chained with `&&`, but 21 of them
|
||||
* finish in under 700ms — they are node-process-startup, not work. The chain's cost is
|
||||
* the SUM of the steps while the machine sits on 7 idle cores. The two dominators
|
||||
* (check:mjs-parse 26.9s, check:parse 18.7s) alone are 54% of the total, so every cheap
|
||||
* gate behind them is pure queueing delay. Same shape for the 22 vite builds: 129.0s
|
||||
* sequential, each writing to its OWN public/doxa-maps/<bundle>/ (vite.config.js sets
|
||||
* outDir per bundle, emptyOutDir:false, and emits nothing outside it) — so they were
|
||||
* never actually ordered, only serialized.
|
||||
*
|
||||
* ★ COVERAGE IS NOT REDUCED, AND THAT IS THE POINT. Every step that ran before still runs,
|
||||
* as the same command, graded by the same exit code. Nothing is skipped, sampled,
|
||||
* cached, or made conditional on what changed. The only thing that changes is WHEN each
|
||||
* step starts. A speed-up that drops a gate is not a speed-up, it is a coverage cut with
|
||||
* a stopwatch attached.
|
||||
*
|
||||
* ★ WHY IT REPORTS ALL FAILURES INSTEAD OF STOPPING AT THE FIRST
|
||||
* An `&&` chain hides every failure after the first, so a build with three broken gates
|
||||
* costs three full fix-and-rerun cycles to discover. Within one phase the steps are
|
||||
* independent, so there is no reason to stop: this runs the whole set, prints every
|
||||
* failure's full output, and exits 1. Ordering that DOES matter is still enforced —
|
||||
* build:unlocked keeps `&&` BETWEEN phases, so a source gate still blocks the build, and
|
||||
* the build still blocks the gates that read its output.
|
||||
*
|
||||
* ★ WHY THERE IS NO TIMEOUT HERE
|
||||
* AGENT-BRIEF.md: "A command killed by a short `timeout` is not a failure." A runner that
|
||||
* imposed its own deadline would manufacture failures under exactly the load that makes
|
||||
* parallelism worth having. Steps run to completion; the caller decides.
|
||||
*
|
||||
* ★ WHY PASSING OUTPUT IS SUPPRESSED (and --verbose exists)
|
||||
* These gates print teaching prose AFTER their verdict. Interleaving 20 of those streams
|
||||
* produces something no one can read, and the brief already warns that `| tail -1`
|
||||
* inverts the signal. So: passing steps report one table line, failing steps report their
|
||||
* ENTIRE captured output, unabridged. --verbose prints everything.
|
||||
*
|
||||
* USAGE
|
||||
* node internal/run-parallel.mjs --label=pre -- check:a check:b ... # runs `bun run <name>`
|
||||
* node internal/run-parallel.mjs --bundles # BUNDLE=<d> bun x vite build, for every plugin-designs/<d>/
|
||||
* env: GATES_CONCURRENCY (default min(cpus,6)) · BUILD_CONCURRENCY (default 4)
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { readdirSync, existsSync } from 'node:fs'
|
||||
import { cpus } from 'node:os'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const VERBOSE = argv.includes('--verbose')
|
||||
const BUNDLES = argv.includes('--bundles')
|
||||
const labelArg = argv.find((a) => a.startsWith('--label='))
|
||||
const LABEL = labelArg ? labelArg.slice('--label='.length) : BUNDLES ? 'bundles' : 'gates'
|
||||
|
||||
// everything after `--` (or every bare non-flag arg) is a step name
|
||||
const dashdash = argv.indexOf('--')
|
||||
const names = (dashdash === -1 ? argv : argv.slice(dashdash + 1)).filter((a) => !a.startsWith('--'))
|
||||
|
||||
/**
|
||||
* WHY TWO DIFFERENT DEFAULTS: a gate is one short-lived node process (startup-bound, ~cheap
|
||||
* RAM), while a vite build holds a whole rollup graph in memory. Measured on this box:
|
||||
* 15GB total with ~6GB available while other agents run browsers, and a vite build peaks
|
||||
* in the hundreds of MB — so 22-wide would swap the machine to death and report it as a
|
||||
* slow build. 4-wide is ~2GB and leaves the box usable for everyone else on it.
|
||||
*/
|
||||
const CONCURRENCY = Math.max(
|
||||
1,
|
||||
Number(
|
||||
BUNDLES
|
||||
? process.env.BUILD_CONCURRENCY || 4
|
||||
: process.env.GATES_CONCURRENCY || Math.min(cpus().length, 6)
|
||||
) || 1
|
||||
)
|
||||
|
||||
/** @returns {{name:string, cmd:string, args:string[], env:Record<string,string>}[]} */
|
||||
function buildSteps() {
|
||||
if (!BUNDLES) {
|
||||
if (!names.length) {
|
||||
console.error('run-parallel: no steps given. Pass script names after `--`.')
|
||||
process.exit(2)
|
||||
}
|
||||
return names.map((name) => ({ name, cmd: 'bun', args: ['run', name], env: {} }))
|
||||
}
|
||||
// A bundle is a plugin-designs/<name>/ directory — the same selector build:unlocked used
|
||||
// (`for d in plugin-designs/*/`), so the SET of things built is identical.
|
||||
const dir = resolve(ROOT, 'plugin-designs')
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort()
|
||||
.map((name) => ({ name, cmd: 'bun', args: ['x', 'vite', 'build'], env: { BUNDLE: name } }))
|
||||
}
|
||||
|
||||
function runStep(step) {
|
||||
return new Promise((done) => {
|
||||
const started = Date.now()
|
||||
const child = spawn(step.cmd, step.args, {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, ...step.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let out = ''
|
||||
const cap = (buf) => {
|
||||
out += buf
|
||||
}
|
||||
child.stdout.on('data', cap)
|
||||
child.stderr.on('data', cap)
|
||||
child.on('error', (err) => {
|
||||
// spawn itself failed (missing binary) — a real failure, not a gate verdict
|
||||
done({ ...step, ms: Date.now() - started, code: 127, out: String(err && err.message) })
|
||||
})
|
||||
child.on('close', (code) => done({ ...step, ms: Date.now() - started, code: code ?? 1, out }))
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const steps = buildSteps()
|
||||
const t0 = Date.now()
|
||||
console.log(
|
||||
`▶ ${LABEL}: ${steps.length} step(s), ${CONCURRENCY} at a time ` +
|
||||
`(${cpus().length} cpus; override with ${BUNDLES ? 'BUILD_CONCURRENCY' : 'GATES_CONCURRENCY'})`
|
||||
)
|
||||
|
||||
const queue = steps.slice()
|
||||
const results = []
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const step = queue.shift()
|
||||
if (!step) return
|
||||
const r = await runStep(step)
|
||||
results.push(r)
|
||||
console.log(
|
||||
` ${r.code === 0 ? '✓' : '✗'} ${r.name.padEnd(26)} ${String(r.ms).padStart(7)}ms` +
|
||||
(r.code === 0 ? '' : ` exit ${r.code}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, steps.length) }, worker))
|
||||
|
||||
const wall = Date.now() - t0
|
||||
const serial = results.reduce((a, r) => a + r.ms, 0)
|
||||
const failed = results.filter((r) => r.code !== 0)
|
||||
|
||||
if (VERBOSE) for (const r of results) if (r.out.trim()) console.log(`\n───── ${r.name} ─────\n${r.out}`)
|
||||
// A failure must be readable in full: these gates explain themselves in their output, and
|
||||
// a truncated explanation is how a real defect gets re-diagnosed as a flake.
|
||||
else for (const r of failed) console.log(`\n───── FAILED: ${r.name} (exit ${r.code}) ─────\n${r.out}`)
|
||||
|
||||
console.log(
|
||||
`\n▶ ${LABEL}: ${results.length - failed.length}/${results.length} passed in ${(wall / 1000).toFixed(1)}s wall ` +
|
||||
`(${(serial / 1000).toFixed(1)}s of work — ${(serial / Math.max(wall, 1)).toFixed(1)}x)`
|
||||
)
|
||||
if (failed.length) {
|
||||
console.error(`✗ ${LABEL}: ${failed.length} step(s) FAILED — ${failed.map((f) => f.name).join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('run-parallel: crashed —', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -62,7 +62,16 @@ useShadowStyles(
|
|||
'.seg button{ position:relative; display:flex; align-items:center; gap:6px;' +
|
||||
' border:0; background:none; color:var(--ui-text-muted,#6d766e);' +
|
||||
' padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;' +
|
||||
' transition:color .15s; font-family:inherit; }' +
|
||||
' transition:color .15s; font-family:inherit;' +
|
||||
// ONE LINE PER TAB. Ported from the shell's own mobile rule (app-wrapper-material
|
||||
// index.html, max-width:960px: white-space:nowrap + ellipsis) — the extraction that
|
||||
// created this component copied the DESKTOP metrics verbatim but left that hardening
|
||||
// behind, so the leaf and the shell disagreed on narrow viewports.
|
||||
// MEASURED at 390px with the label "Prayer Resources": the fieldset is width-capped
|
||||
// by the header, the TEXT wrapped inside the button (28 -> 43px) and the strip grew
|
||||
// 45 -> 60px, while the shell held 45px. The row of tabs never wrapped; only the
|
||||
// words inside one did. Ellipsis is the better failure than a taller header.
|
||||
' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }' +
|
||||
'.seg button::after{ content:""; position:absolute; left:8px; right:8px; bottom:0;' +
|
||||
' height:2px; border-radius:2px 2px 0 0; background:transparent;' +
|
||||
' transition:background .15s; }' +
|
||||
|
|
|
|||
|
|
@ -76,7 +76,17 @@ function place() {
|
|||
const offset = onRight ? (ar.right - tr.left + gap) : (tr.right - ar.left + gap)
|
||||
const width = Math.max(200, Math.min(props.width, ar.width - offset - edge))
|
||||
const top = Math.max(0, tr.top - ar.top)
|
||||
let maxHeight = Math.max(120, ar.height - top - edge) // fit the content inside the map
|
||||
// "Fit inside the map" is the rule ONLY for a real map area. When ShareButton mounted in a
|
||||
// page HEADER, its fallback self-tagged that header band as the area (data-map-area=
|
||||
// "share-fallback", see ShareButton.bindAnchor) -- and this line then dutifully clamped the
|
||||
// menu INSIDE a ~56px band: measured on upg-100-mfe, a 291px share menu rendered with a 69px
|
||||
// scrolling body (BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED). A menu must never
|
||||
// outgrow a MAP's walls, but a header band is not a wall -- nothing clips below it -- so the
|
||||
// fallback area bounds against the VIEWPORT instead and the menu opens at natural height.
|
||||
const isFallbackArea = area.getAttribute && area.getAttribute('data-map-area') === 'share-fallback'
|
||||
let maxHeight = isFallbackArea
|
||||
? Math.max(120, (window.innerHeight || 900) - tr.top - edge) // header host: the page below is available
|
||||
: Math.max(120, ar.height - top - edge) // map host: fit inside the map
|
||||
if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height))
|
||||
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
||||
style.value = {
|
||||
|
|
|
|||
|
|
@ -148,7 +148,24 @@ function positionMenu() {
|
|||
// no edge-sharing placement is reachable — fall back to the hard clamp, since an
|
||||
// unreachable menu is worse than a detached one. That last case cannot mislead: the
|
||||
// trigger is not on screen to look detached FROM.
|
||||
const width = Math.round(Math.min(r.width, vw - EDGE * 2))
|
||||
// ── ★ THE MENU IS NOT THE TRIGGER'S WIDTH (card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) ──
|
||||
// DRIVER: "Make the filters very NARROW, but then the dropdown menu that opens up can be as wide
|
||||
// as you want it to be… so you can fit MORE OF THEM ON ONE ROW WITHOUT WORD WRAPPING."
|
||||
//
|
||||
// THE COUPLING THAT MADE THAT IMPOSSIBLE was this one line: the menu was `min(r.width, …)` — the
|
||||
// TRIGGER's measured width. The two boxes have OPPOSITE constraints and were sharing one number:
|
||||
// - the CLOSED TRIGGER competes for a shared row, so every px it takes is a px another axis
|
||||
// cannot have; it should be as small as its label allows.
|
||||
// - the OPEN MENU is transient and overlays everything (position:fixed, z-index above the
|
||||
// world), so its width costs the layout NOTHING — no sibling reflows around it.
|
||||
// Sizing them together meant narrowing the trigger also narrowed the menu, truncating the option
|
||||
// labels that are the whole reason a narrow trigger is acceptable. So they are separated here.
|
||||
//
|
||||
// The menu is therefore max(trigger, MENU_MIN) and still capped to the viewport. It is a FLOOR,
|
||||
// not a fixed size: a trigger WIDER than the floor (a consumer that raised --dx-filter-w) keeps
|
||||
// the old edge-matched look, so this cannot regress a host that was already happy.
|
||||
const MENU_MIN = 240
|
||||
const width = Math.round(Math.min(Math.max(r.width, MENU_MIN), vw - EDGE * 2))
|
||||
const triggerOnScreen = r.right > EDGE && r.left < vw - EDGE
|
||||
let left
|
||||
if (!triggerOnScreen) {
|
||||
|
|
@ -361,16 +378,49 @@ onBeforeUnmount(() => {
|
|||
--dx-filter-w is a TOKEN, not a literal, for the same reason every colour here is: this
|
||||
component is shared by people-groups-grid, big-picture AND the research-dashboard, so a
|
||||
consumer that needs a different trigger size sets the token at :host instead of forking the
|
||||
component. 168px is sized to the Driver's brief — "just big enough to express the first or
|
||||
second word" — measured against the real labels: it shows "Region: North Am…", i.e. the axis
|
||||
name plus the first word or two, which is what makes the row scannable without reading it. */
|
||||
component. 168px was sized to an earlier brief — "just big enough to express the first or
|
||||
second word".
|
||||
|
||||
── ★ 168px -> 128px: THE TRIGGER AND THE MENU ARE SIZED SEPARATELY NOW ──
|
||||
(card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) DRIVER, four times: "the filters are way too
|
||||
wide. Make the filters very NARROW… so you can fit MORE OF THEM ON ONE ROW WITHOUT WORD WRAPPING.
|
||||
And that way we can decrease the thickness of the headers."
|
||||
|
||||
The trigger no longer has to carry the SELECTED VALUE legibly, because positionMenu() now floors
|
||||
the open menu at 240px independent of this width (see its MENU_MIN note). That is what unlocks
|
||||
the narrowing: the full value is always readable in the menu, so the closed trigger only has to
|
||||
carry the AXIS NAME plus an ellipsis hint — which is all the Driver ever reads it for.
|
||||
|
||||
128px IS MEASURED, NOT PICKED — and the first number tried (116px) was measured WRONG, which is
|
||||
why the measurement is recorded here rather than the conclusion alone. The chrome budget is:
|
||||
2px border + 16px horizontal padding (2 x --dx-filter-pad-x, trimmed 12 -> 8 with this card) +
|
||||
6px gap + 12px chevron = 36px. So a 128px trigger leaves 92px for text.
|
||||
|
||||
Every axis label rendered at 0.875rem/system-ui and measured live:
|
||||
Bloc 29.8 · Status 44.6 · Prayer 45.2 · People 46.5 · Region 47.9 · Country 55.6 ·
|
||||
Religion 55.7 · Language 69.1 · Engagement 89.3
|
||||
116px was chosen first, on the reasoning that "Language" (69.1) was the longest of the SEVEN
|
||||
CURRENTLY SHIPPED and fit its 80px budget with room to spare. That was true and still wrong:
|
||||
this component is SHARED, and this file's own history records an eighth "engagement/Status" axis
|
||||
that has been present before. At 89.3px "Engagement" overflowed the 116px budget by 9.3px — the
|
||||
axis NAME would have ellipsised, which is the one truncation that must never happen (a trigger
|
||||
reading "Engageme…" is unscannable, and unlike a truncated VALUE it has no menu to recover it).
|
||||
128px fits the longest KNOWN label with 2.7px to spare and is still a 24% cut from 168px.
|
||||
|
||||
The floor is a TYPE metric: it moves only if the labels or the font do — and the font is not
|
||||
allowed to move (below). If an axis longer than "Engagement" is ever added, re-measure rather
|
||||
than letting it truncate.
|
||||
|
||||
★ THE FONT-SIZE IS NOT A LEVER. The Driver has rejected shrinking it twice ("super tiny text
|
||||
that cannot be read"). Width, padding and TRIGGER-label truncation are the only tools; 0.875rem
|
||||
on .filter-dropdown__trigger is load-bearing and must not be traded for density. */
|
||||
.filter-dropdown { position: relative; flex: 0 0 auto;
|
||||
width: var(--dx-filter-w, 168px); min-width: var(--dx-filter-w, 168px); max-width: var(--dx-filter-w, 168px); }
|
||||
width: var(--dx-filter-w, 128px); min-width: var(--dx-filter-w, 128px); max-width: var(--dx-filter-w, 128px); }
|
||||
/* Mobile keeps the SAME fixed-box contract — only the number shrinks. The old rule here was
|
||||
`flex:1 1 100%`, which full-widthed the trigger and wrapped the row; that is the growth bug in
|
||||
its most extreme form, so it cannot come back as a "mobile exception". */
|
||||
@media (max-width: 576px) { .filter-dropdown {
|
||||
width: var(--dx-filter-w-sm, 140px); min-width: var(--dx-filter-w-sm, 140px); max-width: var(--dx-filter-w-sm, 140px); } }
|
||||
width: var(--dx-filter-w-sm, 108px); min-width: var(--dx-filter-w-sm, 108px); max-width: var(--dx-filter-w-sm, 108px); } }
|
||||
/* ★ THE TRIGGER'S HEIGHT IS THE FILTER ROW'S HEIGHT, and it was the last unattributed term
|
||||
(card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED — "the filter row is way too thick").
|
||||
MEASURED at 1600x900 standalone, the filters band's 67px decomposes with nothing left over:
|
||||
|
|
@ -382,14 +432,17 @@ onBeforeUnmount(() => {
|
|||
padding 8->5, the selections row 24->18). The trigger was never touched, and at 30px it is the
|
||||
single largest contributor — which is why the band kept measuring ~67 no matter how much was
|
||||
shaved off the others. Vertical padding 6 -> 4 takes the trigger to 26 and the band to 63.
|
||||
★ VERTICAL ONLY. The horizontal 12px is UNCHANGED and must stay: `check:filter-width` asserts
|
||||
every trigger is exactly --dx-filter-w (168px) before and after selecting the longest option,
|
||||
and the label's ellipsis budget is that width minus this padding and the chevron. Touching the
|
||||
horizontal value would move a gate that exists to keep the centred stack from re-flowing.
|
||||
Tokenized so a consumer can trade density back without forking the component, matching how
|
||||
--dx-filter-w already handles the width. */
|
||||
★ THE HORIZONTAL PADDING IS NOW A LEVER TOO — 12px -> 8px, and the earlier "must stay" note it
|
||||
replaces was over-stated (card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU). That note said
|
||||
touching it "would move a gate", having read check:filter-width as asserting a literal 168px.
|
||||
It does not: the gate records every trigger's width with nothing selected and requires the SAME
|
||||
widths after selecting the longest option — it asserts STABILITY, not a number. A uniform
|
||||
narrowing moves both arrays together and the gate stays green (verified by running it).
|
||||
The 8px is spent where it is worth most: it is 8px of the 72px text budget handed back to the
|
||||
axis label, which is what lets the trigger reach 116px without clipping "Language".
|
||||
Tokenized for the same reason as the width — a consumer trades density back without forking. */
|
||||
.filter-dropdown__trigger {
|
||||
width: 100%; padding: var(--dx-filter-pad-y, 4px) 12px; cursor: pointer; display: flex; justify-content: space-between;
|
||||
width: 100%; padding: var(--dx-filter-pad-y, 4px) var(--dx-filter-pad-x, 8px); cursor: pointer; display: flex; justify-content: space-between;
|
||||
/* Pairs its own foreground with its own background for the same reason as the menu:
|
||||
`color: inherit` over a light background rendered near-white-on-white inside the
|
||||
cap-4 dark header. Background and text are set together, never half-inherited. */
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ A zero-dependency Fuse.js-shaped matcher (so no npm dep is added to the shared
|
|||
```js
|
||||
const fuse = new Fuse(list, {
|
||||
keys: ['name', 'country_code.label', 'religion.label'], // dotted paths
|
||||
threshold: 0, // 0 = exact substring only; >0 = fuzzy subsequence
|
||||
threshold: 0, // 0 = exact substring only; >0 = fuzzy (k-errors edit distance, Fuse-parity)
|
||||
minMatchCharLength: 3, // shortest run counted as a match
|
||||
})
|
||||
fuse.search(term) // -> [{ item, matches: [{ key, indices: [[start,end], …] }] }]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* with { keys, threshold, includeMatches, ignoreLocation, minMatchCharLength }. We reproduce
|
||||
* exactly the parts of that contract the grid relies on:
|
||||
* - keys: array of dotted paths ('name', 'country_code.label', …) read from each item
|
||||
* - threshold: 0 = exact substring only; >0 = fuzzy (subsequence) allowed
|
||||
* - threshold: 0 = exact substring only; >0 = fuzzy (k-errors edit distance, Fuse-parity)
|
||||
* - minMatchCharLength: shortest run counted as a match
|
||||
* - .search(term) -> [{ item, matches:[{ key, indices:[[start,end], …] }] }]
|
||||
* The `matches[].indices` drive the <span class="search-highlight"> wrapping in the grid,
|
||||
|
|
@ -43,29 +43,72 @@ function exactIndices(haystack, needle) {
|
|||
return out
|
||||
}
|
||||
|
||||
// Fuzzy (subsequence) match: every char of `needle` appears in order in `haystack`.
|
||||
// Returns the runs of consecutive matched positions, or null if not all chars matched.
|
||||
function fuzzyIndices(haystack, needle) {
|
||||
// ── Fuzzy match, FUSE-PARITY (2026-09-23) ────────────────────────────────────────────────
|
||||
// This used to be a strict SUBSEQUENCE matcher ("every char of needle appears in order"), and
|
||||
// that is NOT what Fuse.js does. The difference bit in production: DX-life's real Fuse
|
||||
// (threshold 0.4, ignoreLocation) tolerates TYPOS -- an extra trailing letter ("Bhutann"),
|
||||
// a substituted one ("Indoa") -- because bitap scores k errors against the pattern length.
|
||||
// A subsequence can only ever DROP haystack chars; an EXTRA needle char always fails it.
|
||||
// Measured on the live dashboard: 'Bhutan' -> 13 rows, 'Bhutann' -> 0. The Driver's test.
|
||||
//
|
||||
// So this is the standard approximate-substring DP (Sellers/Ukkonen "k-errors" matching):
|
||||
// best edit distance of the needle against ANY window of the haystack (free start, free end),
|
||||
// matching iff errors/needle.length <= threshold -- the same score contract Fuse applies with
|
||||
// ignoreLocation:true. Highlight indices are the best-scoring window's char span.
|
||||
function approxIndices(haystack, needle, threshold) {
|
||||
const h = haystack.toLowerCase()
|
||||
const n = needle.toLowerCase()
|
||||
const runs = []
|
||||
let hi = 0
|
||||
let runStart = -1
|
||||
let runEnd = -1
|
||||
for (let ni = 0; ni < n.length; ni++) {
|
||||
const ch = n[ni]
|
||||
let found = -1
|
||||
for (let k = hi; k < h.length; k++) {
|
||||
if (h[k] === ch) { found = k; break }
|
||||
const m = n.length
|
||||
if (!m || !h.length) return null
|
||||
const maxErr = Math.floor(threshold * m)
|
||||
if (maxErr <= 0) return null
|
||||
// dp[j] = min errors matching the whole prefix n[0..i] ending at text position j.
|
||||
// Row 0 is all zeros: the match may START anywhere (that is the "search" variant).
|
||||
let prev = new Array(h.length + 1).fill(0)
|
||||
let cur = new Array(h.length + 1)
|
||||
for (let i = 1; i <= m; i++) {
|
||||
cur[0] = i
|
||||
for (let j = 1; j <= h.length; j++) {
|
||||
const sub = prev[j - 1] + (n[i - 1] === h[j - 1] ? 0 : 1)
|
||||
cur[j] = Math.min(sub, prev[j] + 1, cur[j - 1] + 1)
|
||||
}
|
||||
if (found === -1) return null
|
||||
if (runStart === -1) { runStart = runEnd = found }
|
||||
else if (found === runEnd + 1) { runEnd = found }
|
||||
else { runs.push([runStart, runEnd]); runStart = runEnd = found }
|
||||
hi = found + 1
|
||||
;[prev, cur] = [cur, prev]
|
||||
}
|
||||
if (runStart !== -1) runs.push([runStart, runEnd])
|
||||
return runs
|
||||
let best = Infinity, bestEnd = -1
|
||||
for (let j = 1; j <= h.length; j++) if (prev[j] < best) { best = prev[j]; bestEnd = j }
|
||||
if (best > maxErr) return null
|
||||
const start = Math.max(0, bestEnd - m)
|
||||
return [[start, bestEnd - 1]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level predicates shared with the dashboard's search matcher (useDashboardShell.js).
|
||||
*
|
||||
* ★ TIERED, NOT A UNION (Driver 2026-09-24: "fuzzy search is too fuzzy -- Bhutan returns more
|
||||
* results than just the country, and there is no interoperability with the other filters like
|
||||
* selecting it as a place"). The first fuzzy pass returned exact matches PLUS every <=k-error
|
||||
* neighbour, so the EXACT term 'Bhutan' counted 132 rows while the Country filter counted 13 --
|
||||
* two controls disagreeing about the same place. The conventional contract is tiered: when a
|
||||
* term has exact matches, return ONLY those (search now agrees with the facet, 13 = 13); the
|
||||
* k-errors pass exists solely as the ZERO-RESULT typo rescue ('Bhutann', 'Banladesh').
|
||||
* Callers implement the tier over their row set: filter with exactMatches first, and only if
|
||||
* that yields nothing, filter with fuzzyMatches.
|
||||
*/
|
||||
export function exactMatches(text, term) {
|
||||
const raw = String(text ?? '')
|
||||
const q = String(term ?? '').trim()
|
||||
if (!q) return true
|
||||
if (!raw) return false
|
||||
return raw.toLowerCase().includes(q.toLowerCase())
|
||||
}
|
||||
export function fuzzyMatches(text, term, { threshold = 0.4, minMatchCharLength = 3 } = {}) {
|
||||
const raw = String(text ?? '')
|
||||
const q = String(term ?? '').trim()
|
||||
if (!q) return true
|
||||
if (!raw) return false
|
||||
if (raw.toLowerCase().includes(q.toLowerCase())) return true
|
||||
if (threshold === 0 || q.length < minMatchCharLength) return false
|
||||
return approxIndices(raw, q, threshold) !== null
|
||||
}
|
||||
|
||||
export default class Fuse {
|
||||
|
|
@ -81,22 +124,30 @@ export default class Fuse {
|
|||
if (!q) return this.list.map(item => ({ item, matches: [] }))
|
||||
if (q.length < this.minMatchCharLength) return []
|
||||
const exactOnly = this.threshold === 0
|
||||
// TIER 1: exact substring across the whole list. If ANY item matches exactly, fuzzy never
|
||||
// runs -- an exact term returns exactly the rows the equivalent facet filter selects
|
||||
// (see the tiered-contract note on exactMatches above).
|
||||
const exact = []
|
||||
for (const item of this.list) {
|
||||
const matches = []
|
||||
for (const key of this.keys) {
|
||||
const raw = getByPath(item, key)
|
||||
if (typeof raw !== 'string' || !raw) continue
|
||||
const indices = exactIndices(raw, q)
|
||||
if (indices.length) matches.push({ key, indices })
|
||||
}
|
||||
if (matches.length) exact.push({ item, matches })
|
||||
}
|
||||
if (exact.length || exactOnly) return exact
|
||||
// TIER 2: the zero-result typo rescue (k-errors edit distance).
|
||||
const results = []
|
||||
for (const item of this.list) {
|
||||
const matches = []
|
||||
for (const key of this.keys) {
|
||||
const raw = getByPath(item, key)
|
||||
if (typeof raw !== 'string' || !raw) continue
|
||||
let indices = exactIndices(raw, q)
|
||||
if (indices.length === 0 && !exactOnly) {
|
||||
const fz = fuzzyIndices(raw, q)
|
||||
if (fz) {
|
||||
// keep only runs at least minMatchCharLength long (mirrors Fuse's minMatch)
|
||||
const kept = fz.filter(([s, e]) => e - s + 1 >= Math.min(this.minMatchCharLength, q.length))
|
||||
if (kept.length) indices = kept
|
||||
}
|
||||
}
|
||||
if (indices.length) matches.push({ key, indices })
|
||||
const fz = approxIndices(raw, q, this.threshold)
|
||||
if (fz) matches.push({ key, indices: fz })
|
||||
}
|
||||
if (matches.length) results.push({ item, matches })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
|
|||
|
||||
// Named styles: the 12-style pack. A profile-config style of 'ink' /
|
||||
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
|
||||
import { resolveNamedStyle } from '../styles/registry.js'
|
||||
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
|
||||
|
||||
/** The live engine global for a provider, or undefined if not yet loaded. */
|
||||
function engineGlobal(provider) {
|
||||
|
|
@ -238,6 +238,12 @@ export function useMapInstance({
|
|||
// A pinned value is therefore never lowered, only raised. Leave it undefined to
|
||||
// get the pure dynamic floor.
|
||||
minZoom = undefined,
|
||||
// ★ skipWorldFloor: a GLOBE host opts out of the desktop no-duplicate-world minZoom floor.
|
||||
// That floor exists to stop a wide mercator viewport showing the world twice; on the globe
|
||||
// projection there is no second world -- the planet is a sphere -- so the floor only steals
|
||||
// zoom-out range (measured: it pinned the dashboard at ~1.45 when the whole point of a globe
|
||||
// is pulling back to see it whole). The pinned minZoom (or the engine floor) still applies.
|
||||
skipWorldFloor = false,
|
||||
maxZoom = 18,
|
||||
pitch = 0,
|
||||
bearing = 0,
|
||||
|
|
@ -351,6 +357,7 @@ export function useMapInstance({
|
|||
// up automatically on an orientation/breakpoint flip into mobile.
|
||||
const isDesktop = () => typeof window !== 'undefined' && window.innerWidth >= 1024
|
||||
const floorFor = (W, H) => {
|
||||
if (skipWorldFloor) return (typeof minZoom === 'number') ? minZoom : MIN_ZOOM_FLOOR
|
||||
if (!isDesktop()) return MIN_ZOOM_FLOOR // mobile/tablet — no floor
|
||||
const f = computeWorldMinZoom(W, H, maxZoom, southCutoff)
|
||||
return (typeof minZoom === 'number') ? Math.max(minZoom, f) : f
|
||||
|
|
@ -547,6 +554,19 @@ export function useMapInstance({
|
|||
}
|
||||
|
||||
map.value.on('style.load', () => {
|
||||
// ── NAMED-STYLE PAINT PATCH (e.g. 'galaxy': blue water on a black ground) ──────────────
|
||||
// style.load is the ONLY correct moment: setPaintProperty before the style document has
|
||||
// parsed throws, and re-applying here also survives a later setStyle() — the event fires
|
||||
// again, so a style swap cannot silently drop the patch.
|
||||
// Nothing happens for styles with no `paint` in the registry, so every existing map is
|
||||
// byte-for-byte unchanged.
|
||||
if (_named.paint && map.value) {
|
||||
const hit = applyStylePaint(map.value, _named.paint)
|
||||
// Log what the LOADED style actually had — layer ids differ between Mapbox and MapLibre
|
||||
// and have moved across Mapbox style versions, so this is the only trustworthy record
|
||||
// of which ids are real. A patch that matched nothing is a silent no-op otherwise.
|
||||
console.info('[useMapInstance] style paint applied (' + provider + '):', hit.join(', ') || 'NONE MATCHED')
|
||||
}
|
||||
// Resize after style.load in a rAF: container layout may not have settled yet,
|
||||
// especially when the panel was hidden (display:none) at startup.
|
||||
requestAnimationFrame(() => {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@
|
|||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { bundleTreeRoot } from '@map/utils/embedBase.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.
|
||||
|
|
@ -102,10 +103,31 @@ const _stlLoaded = {}
|
|||
// ever fix a path, never break one that was already working.
|
||||
function resolveBundleSrc(url) {
|
||||
if (!url || /^(https?:)?\/\//.test(url) || url.startsWith('/')) return url
|
||||
const m = /^(.*\/doxa-maps\/)/.exec(window.location.pathname)
|
||||
if (!m) return url
|
||||
const bare = url.replace(/^(\.\.\/)+/, '').replace(/^\.\//, '')
|
||||
return m[1] + bare
|
||||
// (1) The document is somewhere inside the tree — anchor on its own /doxa-maps/ segment.
|
||||
const m = /^(.*\/doxa-maps\/)/.exec(window.location.pathname)
|
||||
if (m) return m[1] + bare
|
||||
// (2) THE DOCUMENT IS NOT INSIDE THE TREE AT ALL, so there is no segment to anchor on.
|
||||
// Driver-reported 2026-09-23: <plugin-studio> mounts at the SERVER ROOT (/index.html) in both
|
||||
// trees, and from there this function's only anchor was missing, so it returned the literal
|
||||
// '../../doxa-research-map/doxa-research-map.js' unchanged. The browser resolved that against
|
||||
// '/' — where '../' cannot climb — and requested /doxa-research-map/doxa-research-map.js:
|
||||
// 404, and StackLayout's .catch() reported "Could not load <doxa-research-map>".
|
||||
// MEASURED on the built tree: the SAME composite opened at its own leaf renders perfectly, which
|
||||
// is why this survived — every gate and every screenshot used a leaf URL, and the studio is the
|
||||
// one host that is not one.
|
||||
// The answer is the tree root the BUNDLE ITSELF was served from. bundleTreeRoot() reads this
|
||||
// bundle's own script URL (rollup rewrites import.meta.url to document.currentScript.src in the
|
||||
// IIFE build), so it is a FACT about where the code came from rather than a guess about where the
|
||||
// page is — which is the lesson stack-demo.vue already wrote down after this same defect appeared
|
||||
// for the third time: "resolve at RUNTIME from something that knows which tree is serving."
|
||||
// It returns null in dev ESM and for any layout that is not <name>/<name>.js, so it never guesses.
|
||||
const root = bundleTreeRoot()
|
||||
if (root) return String(root).replace(/\/+$/, '') + '/' + bare
|
||||
// (3) Nothing knowable — return the original untouched. Together with (1) coming first, this
|
||||
// keeps every path that worked before byte-identical: this function can still only ever fix a
|
||||
// path, never break one.
|
||||
return url
|
||||
}
|
||||
|
||||
function injectScriptOnce(rawUrl) {
|
||||
|
|
|
|||
|
|
@ -380,7 +380,17 @@ const VTL_SHADOW_CSS = `
|
|||
one viewport. The clipping now belongs to .vtl-host, which owns a real scrollbar, so the content
|
||||
is both contained AND reachable. Dropping paint costs the collapse nothing: paint never
|
||||
contributed reflow isolation, only clipping. */
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
|
||||
/* ★ NO containment AT ALL on .vtl-content (BUG-FULLSCREEN-MODALS-UNDERSIZED-INSIDE-THE-CAP5-SHELL,
|
||||
measured 2026-09-23). contain:paint was dropped earlier for the scroll bug; contain:layout
|
||||
stayed -- but LAYOUT containment ALSO makes this element the containing block for every
|
||||
position:fixed descendant (CSS Containment spec), so all six modal scrims hosted in the shell
|
||||
(PosterDialog, DataTable .dt-scrim, LegendTools .lt-overlay, SemanticTreeLegend export
|
||||
backdrop, both poster-wall .modal-scrim) resolved inset:0 to the CONTENT PANE: measured, a
|
||||
fixed inset:0 probe here read x=256 w=1184 on a 1440 viewport, leaving the 256px nav rail
|
||||
uncovered and CLICKABLE behind an open modal. Visibly almost right, interactively wrong.
|
||||
The perf rationale it was kept for does not hold: when the nav collapses, this element's own
|
||||
width changes, and a width change re-lays-out the subtree with or without containment. */
|
||||
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). Measured in the cap-5
|
||||
shell: the dashboard-plus-grid composite mounted here reported scrollHeight 3380 / clientHeight
|
||||
769 — 2611px of real, mounted, populated content (the people-groups grid, h=2586) that NO element
|
||||
|
|
@ -813,7 +823,17 @@ export default defineComponent({
|
|||
one viewport. The clipping now belongs to .vtl-host, which owns a real scrollbar, so the content
|
||||
is both contained AND reachable. Dropping paint costs the collapse nothing: paint never
|
||||
contributed reflow isolation, only clipping. */
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
|
||||
/* ★ NO containment AT ALL on .vtl-content (BUG-FULLSCREEN-MODALS-UNDERSIZED-INSIDE-THE-CAP5-SHELL,
|
||||
measured 2026-09-23). contain:paint was dropped earlier for the scroll bug; contain:layout
|
||||
stayed -- but LAYOUT containment ALSO makes this element the containing block for every
|
||||
position:fixed descendant (CSS Containment spec), so all six modal scrims hosted in the shell
|
||||
(PosterDialog, DataTable .dt-scrim, LegendTools .lt-overlay, SemanticTreeLegend export
|
||||
backdrop, both poster-wall .modal-scrim) resolved inset:0 to the CONTENT PANE: measured, a
|
||||
fixed inset:0 probe here read x=256 w=1184 on a 1440 viewport, leaving the 256px nav rail
|
||||
uncovered and CLICKABLE behind an open modal. Visibly almost right, interactively wrong.
|
||||
The perf rationale it was kept for does not hold: when the nav collapses, this element's own
|
||||
width changes, and a width change re-lays-out the subtree with or without containment. */
|
||||
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). Measured in the cap-5
|
||||
shell: the dashboard-plus-grid composite mounted here reported scrollHeight 3380 / clientHeight
|
||||
769 — 2611px of real, mounted, populated content (the people-groups grid, h=2586) that NO element
|
||||
|
|
|
|||
|
|
@ -631,6 +631,11 @@ const VIEWS = [
|
|||
{ id:'tree', label:'Tree', icon:'tree' },
|
||||
]
|
||||
let view = 'map'
|
||||
// Which profile the tab strip was last drawn FOR, so drawSeg can tell "same profile,
|
||||
// user picked a tab" from "profile changed, reset to its first view". Declared with
|
||||
// `var`-like hoisting in mind: it is read inside drawSeg, which only ever runs after
|
||||
// this module body has executed, so a `let` here is safe (cf. DEFAULT_PROFILE below).
|
||||
let segProfile = null
|
||||
// The tabs are PER-PROFILE, not a fixed app-wide set. A profile that declares
|
||||
// `panes` shows exactly those and no others — upg-100 has a map and a poster wall,
|
||||
// so it shows two tabs, not three with a dead one. A profile with no `panes` key
|
||||
|
|
@ -666,7 +671,15 @@ function drawSeg(){
|
|||
if (_sel) _sel.style.display = views.length <= 1 ? 'none' : ''
|
||||
// If the active view is not one this profile has (e.g. you were on Tree, then
|
||||
// switched to a two-pane profile), fall back to its first pane.
|
||||
if (!views.some(v => v.id === view)) view = views[0].id
|
||||
//
|
||||
// ALSO fall back when the profile CHANGED, even if the old view id happens to exist
|
||||
// here too. `view` is one global across profiles and 'map' is a valid id in nearly
|
||||
// every one, so an id-existence test alone let a stale 'map' survive a switch into a
|
||||
// profile whose FIRST pane is not the map (upg-100-list leads with Prayer Resources).
|
||||
// The column always opens at scroll position 0, so the strip then painted a pressed
|
||||
// tab that did not match the pane on screen. A new profile opens on its own first view.
|
||||
if (!views.some(v => v.id === view) || segProfile !== active) view = views[0].id
|
||||
segProfile = active
|
||||
$('#seg').innerHTML = views.map(v =>
|
||||
`<button data-v="${v.id}" aria-pressed="${v.id===view}">${icon(v.icon)}<span>${v.label}</span></button>`
|
||||
).join('')
|
||||
|
|
@ -826,10 +839,23 @@ const PROFILES = {
|
|||
// (index.js does import.meta.glob('./*.vue'), so poster-wall.vue registers
|
||||
// itself). Same tag, same src, different `profile` value: one download serves
|
||||
// both panes and they share data/clusters.json rather than fetching twice.
|
||||
// RESOURCES FIRST, MAP SECOND (Driver 2026-09-23). "By default the first tab
|
||||
// shouldn't be the map. The first tab should be the prayer resources" — that pane
|
||||
// already explains what a UPG-100 list IS at the top and shows the posters, so a
|
||||
// new user needs no explanation for it; a choropleth of zones does. Stated by him
|
||||
// as a general principle: "maps don't have to be primary. You can use lists and
|
||||
// grids for the primary view and maps as a secondary view… then they transition
|
||||
// into the map." Familiarity is the on-ramp.
|
||||
//
|
||||
// Both panes carry an EXPLICIT `config.profile`, including the map. The mount code
|
||||
// used to rely on "pane 0 is the map" positionally — reordering under that rule
|
||||
// would have mounted the map element into the poster pane. Naming the profile on
|
||||
// every pane is what makes the order data rather than convention.
|
||||
panes: [
|
||||
{ id:'map', label:'Map', at:[0,0] },
|
||||
{ id:'list', label:'Prayer Lists', at:[1,0],
|
||||
{ id:'list', label:'Prayer Resources', at:[0,0],
|
||||
config: { profile:'poster-wall', instanceId:'wrapper-upg-100' } },
|
||||
{ id:'map', label:'Map', at:[1,0],
|
||||
config: { profile:'upg-100-list', instanceId:'wrapper-upg-100' } },
|
||||
],
|
||||
},
|
||||
// COUNTRY POSTERS — one poster per country, the same shape as upg-100-list:
|
||||
|
|
@ -1046,13 +1072,25 @@ async function mount(id){
|
|||
slot.innerHTML = ''
|
||||
const column = buildPaneColumn(spec, { host: slot })
|
||||
if (column){
|
||||
// Pane 0 gets the element already built above; every OTHER pane that declares
|
||||
// its own `config` gets a sibling element from the SAME bundle (same tag, same
|
||||
// src — one download) with a different `profile`. A pane with no config keeps
|
||||
// its placeholder, so an unbuilt view never blocks the column.
|
||||
column.mountEl(column.panes[0].id)?.appendChild(el)
|
||||
// The element already built above goes to the pane that ASKED FOR ITS PROFILE;
|
||||
// every OTHER pane that declares its own `config` gets a sibling element from
|
||||
// the SAME bundle (same tag, same src — one download) with a different
|
||||
// `profile`. A pane with no config keeps its placeholder, so an unbuilt view
|
||||
// never blocks the column.
|
||||
//
|
||||
// WHY BY PROFILE, NOT BY INDEX. This used to be `panes[0]`, encoding "the map is
|
||||
// pane 0" as a positional convention. That silently breaks the moment a profile
|
||||
// wants a non-map pane first — upg-100-list now leads with its poster wall — and
|
||||
// the failure is the worst kind: the MAP element mounts into the POSTER pane and
|
||||
// both panes render the map. Panes that name no profile cannot match, so every
|
||||
// other multi-pane profile still lands on pane 0 exactly as before.
|
||||
const hostPane =
|
||||
column.panes.find(p => p.config?.profile && p.config.profile === spec.config?.profile)
|
||||
|| column.panes[0]
|
||||
column.mountEl(hostPane.id)?.appendChild(el)
|
||||
const extras = [el]
|
||||
for (const pane of column.panes.slice(1)){
|
||||
for (const pane of column.panes){
|
||||
if (pane.id === hostPane.id) continue
|
||||
if (!pane.config) continue
|
||||
const pel = document.createElement(spec.tag)
|
||||
const pcfg = { ...spec.config, ...pane.config }
|
||||
|
|
@ -1092,8 +1130,11 @@ async function mount(id){
|
|||
}
|
||||
column._showOnMap = (ev) => {
|
||||
const d = ev?.detail || {}
|
||||
// The map is pane 0 by convention (the element built above mounts there).
|
||||
const mapPaneId = column.panes[0]?.id
|
||||
// The map is the pane the SHELL-BUILT element mounted into — resolved by
|
||||
// profile, not by position. "Pane 0 is the map" stopped being true when
|
||||
// upg-100-list put its poster wall first; under the old rule "Show on map"
|
||||
// scrolled to the poster wall you clicked FROM, which looks like a dead button.
|
||||
const mapPaneId = hostPane?.id || column.panes[0]?.id
|
||||
if (mapPaneId) column.scrollTo(mapPaneId)
|
||||
// Forward to every live element in this column; the map profile picks it
|
||||
// up and selects the zone. A DESELECT arrives as listId === null, which
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ const FORMATS = {
|
|||
// and those already derive from MAP_BREATH/HEADER rather than hardcoded offsets.
|
||||
// Serves China (154 groups) and is the sheet the shelved granular 4-column table
|
||||
// (F93) was waiting on — build it once, both are served.
|
||||
'3x4': { w: 1500, h: 2000, gutter: 330, page: '3in 4in' },
|
||||
'3x4': { w: 1500, h: 2000, gutter: 330, page: '3in 4in', pageWidthIn: 3 },
|
||||
// Square: a 3ft x 3ft sheet. The map loses height relative to 3x4, so the
|
||||
// gutters widen slightly — with fewer label rows per column the names can take
|
||||
// more width, and the map stays close to square rather than becoming a letterbox.
|
||||
|
|
@ -273,7 +273,23 @@ const FORMATS = {
|
|||
// be line wrapped — like 1 and 6 and 34, 49, 44." At 330 the name column is
|
||||
// 222px and only "Vellaiyan Kuppam Padaiyaci" (265px) still wraps, which is a
|
||||
// name that genuinely overruns any sane column.
|
||||
'3x3': { w: 1500, h: 1500, gutter: 330, page: '3in 3in' },
|
||||
'3x3': { w: 1500, h: 1500, gutter: 330, page: '3in 3in', pageWidthIn: 3 },
|
||||
// LETTER PORTRAIT — the same sheet, sized for paper a normal printer can take.
|
||||
// Brief: "keep the layout of the poster the same with the same text, just
|
||||
// formatted for a vertical piece of paper."
|
||||
//
|
||||
// WIDTH STAYS 1500 for the reason the 3x4 note gives above: every width-derived
|
||||
// measurement (the 330 gutter, the name columns, the header budget) was calibrated
|
||||
// at 1500 and carries over UNCHANGED. Height is the only variable: 8.5x11 at
|
||||
// width 1500 -> 1500 * (11/8.5) = 1941. That lands between 3x3 (1500) and 3x4
|
||||
// (2000), so the map keeps roughly the 3x3 proportions with ~29% more height for
|
||||
// the label columns — the sheet gets taller, nothing gets re-tuned.
|
||||
//
|
||||
// `page` is what the generated HTML puts in @page{size:...}. Naming the real
|
||||
// paper size (rather than an inch pair) lets the browser's own Letter preset
|
||||
// match it, which is what makes an ordinary printer produce a full-bleed-free,
|
||||
// correctly-scaled sheet instead of "fit to page" guesswork.
|
||||
letter: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5 },
|
||||
};
|
||||
const FMT = FORMATS[process.env.FORMAT || '3x4'] || FORMATS['3x4'];
|
||||
// Stamped at generation time. Overridable so a rebuild can reproduce an existing
|
||||
|
|
@ -2055,9 +2071,34 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
const credit = (x, y, anchor, txt) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" class="attrib-halo">${txt}</text>`
|
||||
+ `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" class="attrib">${txt}</text>`;
|
||||
// ATTRIBUTION CORRECTED 2026-09-23. This said "(c) Mapbox (c) OpenStreetMap",
|
||||
// which was inherited boilerplate and simply not true of this sheet: the header
|
||||
// of this file states NO Mapbox, NO token, NO external fetch at render time, and
|
||||
// the basemap is drawn from GeoJSON bundled in the repo. Crediting a vendor that
|
||||
// contributes nothing implies a dependency that does not exist \u2014 the kind of line
|
||||
// that makes a later reader add a token "to comply".
|
||||
// The real source is geoBoundaries (the adm0/adm1 files carry its shapeGroup /
|
||||
// shapeName fields), CC BY 4.0, which REQUIRES attribution \u2014 so the credit stays,
|
||||
// it just names who actually earned it. OpenStreetMap is named only when OSM
|
||||
// street tiles are actually fetched (BASEMAP=1, city sheets).
|
||||
const basemapCredit = BASEMAP
|
||||
? '\u00a9 geoBoundaries \u00b7 \u00a9 OpenStreetMap'
|
||||
: '\u00a9 geoBoundaries (CC BY 4.0)';
|
||||
// \u2605 PRIVACY LINE. The people-group coordinates arrive already generalised from
|
||||
// the data publisher \u2014 they are deliberately NOT precise locations. A printed
|
||||
// sheet travels far beyond the person who generated it, so it has to carry that
|
||||
// fact itself rather than rely on the reader knowing it.
|
||||
// Worded as provenance ("generalised at the source"), not as a claim about
|
||||
// processing we perform here, because we do no rounding of our own.
|
||||
const privacyNote = 'Marker positions are approximate \u00b7 people-group locations are generalised at the source for privacy';
|
||||
// The privacy line gets its OWN row, 13px above the credits. Sharing the credit
|
||||
// row put it at 516-984px against a stamp ending at 543 \u2014 a measured collision.
|
||||
// It is also a different KIND of text: the other two are ~200px provenance
|
||||
// stamps, this is a sentence a reader must actually READ, so it needs the room.
|
||||
const mapCredits =
|
||||
credit(FRAME_X + 8, FRAME_Y + FRAME_H - 7, 'start', `Generated ${POSTER_DATE} \u00b7 data from doxa.life`)
|
||||
+ credit(FRAME_X + FRAME_W - 8, FRAME_Y + FRAME_H - 7, 'end', '\u00a9 Mapbox \u00a9 OpenStreetMap');
|
||||
credit(FRAME_X + FRAME_W / 2, FRAME_Y + FRAME_H - 20, 'middle', privacyNote)
|
||||
+ credit(FRAME_X + 8, FRAME_Y + FRAME_H - 7, 'start', `Generated ${POSTER_DATE} \u00b7 data from doxa.life`)
|
||||
+ credit(FRAME_X + FRAME_W - 8, FRAME_Y + FRAME_H - 7, 'end', basemapCredit);
|
||||
|
||||
// FIT THE TITLE TO THE SHEET. Region names vary a lot in length ("West India"
|
||||
// vs "Northeast India + Bangladesh + Bhutan"), and the qualifier adds a fixed
|
||||
|
|
@ -2443,7 +2484,26 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
/* Haloed so the credits stay legible wherever they land on the map. */
|
||||
.attrib-halo{font-family:'Segoe UI',system-ui,sans-serif;font-size:9px;fill:none;
|
||||
stroke:#EEF3F4;stroke-width:2.6;stroke-linejoin:round;letter-spacing:.02em}
|
||||
@media print{@page{size:${FMT.page};margin:0}html,body{background:#fff}.stage{margin:0}}
|
||||
/* PRINT. The stage is sized in CSS PIXELS (1500 wide) while @page declares a
|
||||
PHYSICAL size, so a browser maps 1500px -> 96dpi -> 15.6in and TILES that
|
||||
across the sheet: the 3x3 sheet prints as FOUR pages, not one. That went
|
||||
unnoticed because these posters were built to be SCREENSHOT by shoot.mjs at
|
||||
deviceScaleFactor 4, never printed from the browser.
|
||||
Scaling the stage to the page's own width makes one sheet = one page on any
|
||||
format, which is what a home printer needs. transform-origin top-left so the
|
||||
scale collapses toward the page corner rather than the element centre. */
|
||||
@media print{
|
||||
@page{size:${FMT.page};margin:0}
|
||||
html,body{background:#fff}
|
||||
/* A FIXED ratio, computed at generate time — not a vw calc. a vw/px ratio does not
|
||||
resolve against the PAGE box in print, so the calc silently evaluated to a
|
||||
no-op and the sheet still tiled. The page width in CSS px is the paper width
|
||||
in inches x 96; the scale is that over the stage width. */
|
||||
.stage{margin:0;transform:scale(${(FMT.pageWidthIn * 96 / FMT.w).toFixed(6)});transform-origin:top left}
|
||||
/* The scaled stage still reserves its ORIGINAL box, which pushes a blank second
|
||||
page. Collapse the flow height to the scaled height. */
|
||||
html,body{width:${(FMT.pageWidthIn * 96).toFixed(0)}px;height:${(FMT.pageWidthIn * 96 * FMT.h / FMT.w).toFixed(0)}px;overflow:hidden}
|
||||
}
|
||||
</style></head><body><div class="stage">${svg}</div></body></html>`;
|
||||
return { html, title, octiOK, crossings, diagFrac };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,48 @@ export const STYLE_REGISTRY = [
|
|||
mapbox: 'mapbox://styles/mapbox/navigation-night-v1',
|
||||
maplibre: 'https://tiles.openfreemap.org/styles/dark',
|
||||
bestFor: ['research-dashboard'] },
|
||||
|
||||
// ── GALAXY — black ground, BLUE water ────────────────────────────────────────────────────
|
||||
// DRIVER 2026-09-23: "the map style needs to have the BLUE WATER with like the BLACK GALAXY
|
||||
// background". The continents go to deep space; the oceans stay unmistakably water.
|
||||
//
|
||||
// ★ WHY THIS IS A BASE STYLE + A PAINT PATCH, NOT A HAND-AUTHORED style.json. Writing a whole
|
||||
// style document would mean owning ~47 layers, a glyph endpoint and a sprite URL forever, and
|
||||
// it would be MapLibre-only — breaking the registry's contract that every id resolves on BOTH
|
||||
// engines. Instead both providers start from their existing near-black style and we repaint
|
||||
// exactly the layers that carry water. The patch is small, auditable, and provider-symmetric.
|
||||
//
|
||||
// WHAT THE BASE STYLES ACTUALLY PAINT (measured 2026-09-23, not assumed):
|
||||
// OpenFreeMap dark — background rgb(12,12,12); layer id `water` (fill) is rgb(27,27,29) and
|
||||
// `waterway` (line) the same. i.e. the base style does not distinguish
|
||||
// water from land AT ALL. That is precisely the gap this entry fills.
|
||||
// Mapbox dark-v11 — its water layer ids are enumerated at runtime (they differ from
|
||||
// MapLibre's and have changed across style versions), so `paint` lists
|
||||
// CANDIDATES and the applier skips any id the loaded style lacks.
|
||||
//
|
||||
// THE BLUE: #2563a8 measures 3.19:1 against the rgb(12,12,12) ground — above the 3:1 WCAG
|
||||
// non-text threshold, so the coastline is a real edge rather than a tonal hint. Darker,
|
||||
// moodier blues were tried and measured first: #1b3a6b 1.74:1 and #1e4d8f 2.34:1 both fall
|
||||
// UNDER 3:1, which is how an ocean ends up merely looking like slightly-less-black land.
|
||||
/* ★ CORRECTED 2026-09-23 — THE FIRST GALAXY WAS A MISREADING OF THE ASK, and the Driver said so:
|
||||
"I don't want a dark basemap, I didn't ask for one. I asked for a spherical GLOBE with a black
|
||||
background — the galaxy, like the Mapbox map — with BLUE OCEANS like the Mapbox map, and the
|
||||
land was NOT to be black."
|
||||
The first attempt tinted a dark basemap (black land, recoloured water) — the night side of
|
||||
Earth. What he described is the DAY side seen from space: NORMAL land colours, the default
|
||||
blue oceans, and the blackness belongs to the SPACE BEHIND THE SPHERE, not to the ground.
|
||||
So the style is now a normal-coloured basemap with no paint overrides at all — the galaxy is
|
||||
not a property of the tiles. It is the void the globe floats in, which is the map CONTAINER's
|
||||
background showing through outside the sphere (see DashboardMapPanel's .rd-map rule). */
|
||||
{ id: 'galaxy', label: 'Galaxy', mood: 'the day side of Earth floating in space — normal land, blue oceans, black void behind the sphere',
|
||||
mapbox: 'mapbox://styles/mapbox/standard',
|
||||
// ★ FOUND, NOT AUTHORED (Driver: "I want it replaced with something you can find online").
|
||||
// VersaTiles Colorful — a maintained, production-free, keyless style (280 layers) whose ocean
|
||||
// is a real blue (#bfd9f2), the closest of the surveyed free styles to the Mapbox look. The
|
||||
// alternatives measured and rejected: CARTO Voyager's water is pale teal #b0d0d6; OSM
|
||||
// Americana has proper blue but its hosted tiles are a demo endpoint, not for production.
|
||||
maplibre: 'https://tiles.versatiles.org/assets/styles/colorful/style.json',
|
||||
bestFor: ['research-dashboard', 'my-upg-100-list'] },
|
||||
]
|
||||
|
||||
const BY_ID = Object.fromEntries(STYLE_REGISTRY.map(s => [s.id, s]))
|
||||
|
|
@ -92,16 +134,43 @@ const BY_ID = Object.fromEntries(STYLE_REGISTRY.map(s => [s.id, s]))
|
|||
* · a registry NAME → that entry's per-provider style URL
|
||||
* · a raw mapbox:// or http(s):// URL → passed through untouched (legacy behavior)
|
||||
* · anything else / undefined → undefined (caller keeps its own default)
|
||||
* Returns { style, lightPreset } — lightPreset only for named Standard variants.
|
||||
* Returns { style, lightPreset, paint } — lightPreset only for named Standard variants,
|
||||
* `paint` only for entries that patch the base style (see applyStylePaint).
|
||||
*/
|
||||
export function resolveNamedStyle(value, provider = 'mapbox') {
|
||||
if (typeof value !== 'string' || !value) return { style: undefined, lightPreset: undefined }
|
||||
if (typeof value !== 'string' || !value) return { style: undefined, lightPreset: undefined, paint: undefined }
|
||||
const entry = BY_ID[value]
|
||||
if (entry) {
|
||||
return provider === 'maplibre'
|
||||
? { style: entry.maplibre, lightPreset: undefined }
|
||||
: { style: entry.mapbox, lightPreset: entry.lightPreset }
|
||||
? { style: entry.maplibre, lightPreset: undefined, paint: entry.paint }
|
||||
: { style: entry.mapbox, lightPreset: entry.lightPreset, paint: entry.paint }
|
||||
}
|
||||
if (/^(mapbox:\/\/|https?:\/\/)/.test(value)) return { style: value, lightPreset: undefined }
|
||||
return { style: undefined, lightPreset: undefined }
|
||||
if (/^(mapbox:\/\/|https?:\/\/)/.test(value)) return { style: value, lightPreset: undefined, paint: undefined }
|
||||
return { style: undefined, lightPreset: undefined, paint: undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a registry entry's `paint` patch to a LOADED map, skipping any layer the style lacks.
|
||||
*
|
||||
* ★ WHY SKIP-IF-ABSENT IS THE WHOLE DESIGN. Layer ids are a property of the STYLE DOCUMENT, not
|
||||
* of the engine — Mapbox and MapLibre name water differently, and Mapbox has renamed its own ids
|
||||
* across style versions. Hardcoding one id and calling setPaintProperty on it throws and kills
|
||||
* the rest of the patch. Probing getLayer() first turns a wrong guess into a no-op, so a list
|
||||
* covering both engines is safe to apply to either.
|
||||
*
|
||||
* Returns the ids it actually matched, so a caller can log what the loaded style really had
|
||||
* rather than what we hoped it had.
|
||||
*/
|
||||
export function applyStylePaint(map, paint) {
|
||||
if (!map || !Array.isArray(paint)) return []
|
||||
const applied = []
|
||||
for (const p of paint) {
|
||||
if (!p || !p.layer || !p.prop) continue
|
||||
try {
|
||||
if (!map.getLayer(p.layer)) continue
|
||||
map.setPaintProperty(p.layer, p.prop, p.value)
|
||||
applied.push(p.layer + '.' + p.prop)
|
||||
} catch (_) { /* a style that has the layer but not the property: skip, never throw */ }
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
"check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test # reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
|
||||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
"verify:map-paint": "node internal/verify-map-paint.mjs",
|
||||
"check:sri": "node internal/sri-consistency-check.mjs",
|
||||
|
|
@ -126,7 +126,10 @@
|
|||
"serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",
|
||||
"check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs",
|
||||
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
|
||||
"check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails."
|
||||
"check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"type": "single",
|
||||
"label": "Cities with Internet — connectivity map",
|
||||
"config": {
|
||||
"pinHint": false
|
||||
},
|
||||
"note": "pinHint:false because this map has NO DOTS. It shades country polygons by national internet penetration; the shared PinHint's default label invites the reader to 'Tap a coloured dot', which points at something that does not exist here and makes a working map look broken. Opt-out is the documented channel (library/components/hints/PinHint.vue). Sibling: gospel-resources/resources-map.meta.json — keep these two in step."
|
||||
}
|
||||
|
|
@ -147,12 +147,24 @@ const FACET_TO_PROP = {
|
|||
// to join it: imb_gsec is a real row field with 5 {value,label} pairs and no value-vs-label
|
||||
// cross-collision, so valueMatches() selects the same rows whichever vocabulary arrives.
|
||||
gospel_access: 'gospelAccess',
|
||||
// ── THE TWO FORMERLY-DERIVED-ONLY AXES, wired at last (2026-09-23) ──
|
||||
// These were the "STILL DELIBERATELY UNMAPPED" pair above: their values are DERIVED
|
||||
// ("Hinduism" from the religion code's first letter; "Indo-European" from the
|
||||
// imb_language_family label or the ISO lookup), so no fetched row field could match them
|
||||
// and mapping them would have filtered the grid to ZERO. The grid now runs the SAME
|
||||
// derivations itself (people-groups-grid.vue DERIVED_AXES, importing the same @map/colors
|
||||
// + @map/data modules dashboard.config.js reads), so the value the dashboard emits and the
|
||||
// value the grid derives come from one owner and byte-match. With that in place, the top
|
||||
// tiers of the Religion and Language trees drive the grid like every other axis.
|
||||
religion_family: 'religionFamily',
|
||||
language_family: 'languageFamily',
|
||||
}
|
||||
|
||||
// Empty string (not null) is the grid's documented "no filter" value for every axis, so a
|
||||
// cleared facet must RESET its prop rather than leave the previous value stuck.
|
||||
const filter = reactive({ query: '', wagfRegion: '', wagfBlock: '', rop1: '', religion: '', countryCode: '',
|
||||
primaryLanguage: '', engagementStatus: '', cluster: '', peopleGroup: '', gospelAccess: '' })
|
||||
primaryLanguage: '', engagementStatus: '', cluster: '', peopleGroup: '', gospelAccess: '',
|
||||
religionFamily: '', languageFamily: '' })
|
||||
|
||||
function onDashboardFilter({ filterSet = {}, searchTerm = '' } = {}) {
|
||||
filter.query = searchTerm || ''
|
||||
|
|
@ -276,6 +288,8 @@ const sections = [
|
|||
:cluster="filter.cluster"
|
||||
:people-group="filter.peopleGroup"
|
||||
:gospel-access="filter.gospelAccess"
|
||||
:religion-family="filter.religionFamily"
|
||||
:language-family="filter.languageFamily"
|
||||
:show-search="false" :show-filters="false" :show-total="false" />
|
||||
</template>
|
||||
</StackLayout>
|
||||
|
|
|
|||
8
plugin-designs/gospel-resources/resources-map.meta.json
Normal file
8
plugin-designs/gospel-resources/resources-map.meta.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"type": "single",
|
||||
"label": "Gospel Resources — resources map",
|
||||
"config": {
|
||||
"pinHint": false
|
||||
},
|
||||
"note": "pinHint:false because this map has NO DOTS. It shades country polygons by gospel-resource gap/coverage; the shared PinHint's default label invites the reader to 'Tap a coloured dot', which points at something that does not exist here and makes a working map look broken. Opt-out is the documented channel (library/components/hints/PinHint.vue). Sibling: cities-internet/connectivity-map.meta.json — keep these two in step."
|
||||
}
|
||||
|
|
@ -274,38 +274,69 @@ const { scrimRef, scrimStyle } = useViewportScrim(exportState)
|
|||
// all stay — printPoster below still uses posterUrl(), and all three print paths still
|
||||
// share the exportState HUD for progress/errors.
|
||||
|
||||
// ─── SHARED PRINT-WINDOW MECHANISM ────────────────────────────────────────────
|
||||
// ─── SHARED PRINT MECHANISM ───────────────────────────────────────────────────
|
||||
// ONE mechanism, reused by all three "Print …" actions (poster / list / card
|
||||
// deck) rather than three ad-hoc window.opens. Extracted from the poster path,
|
||||
// which already carries the load-bearing fix documented below.
|
||||
//
|
||||
// ★ NO `noopener` HERE, AND THAT IS THE WHOLE FIX (2026-09-23). `noopener` makes window.open()
|
||||
// return null BY SPECIFICATION — severing the opener relationship is exactly what the flag does,
|
||||
// so there is no handle to hand back. With it, `w` was always null, the document.write preview
|
||||
// below was UNREACHABLE FOR EVERY USER, and every click fell through to a raw-content fallback.
|
||||
// MEASURED in a default (non-headless-flagged) browser: window.open('','_blank','noopener') -> null,
|
||||
// the identical call without the flag -> a usable handle. I first attributed the null to this
|
||||
// headless box; it is not environmental, it is what the flag means.
|
||||
// The window we write into carries same-origin markup we generate ourselves, so the opener
|
||||
// reference it regains is our own — there is nothing hostile for `noopener` to protect against here.
|
||||
// deck) rather than three ad-hoc implementations.
|
||||
//
|
||||
// `bodyHtml` is the FULL <html> document string (caller owns <head>/<style>/<body>). The caller's
|
||||
// content is responsible for its own print trigger — pass `autoprint:true` to have this helper
|
||||
// append the `window.focus();window.print()` bootstrap (used by markup with no <img onload> of
|
||||
// its own to hang the call off).
|
||||
//
|
||||
// ── HISTORY, kept because the lesson outlives the code that taught it ──
|
||||
// This helper used to call window.open(). An earlier build passed `noopener`, which makes
|
||||
// window.open() return null BY SPECIFICATION — severing the opener relationship is exactly what
|
||||
// the flag does, so there was no handle to write into. Every click silently fell through to a
|
||||
// raw-content fallback, for every user. MEASURED: window.open('','_blank','noopener') -> null;
|
||||
// the identical call without the flag -> a usable handle. It was first blamed on the headless box;
|
||||
// it was not environmental, it was what the flag means.
|
||||
// The iframe below makes that class of failure unreachable — there is no popup to block and no
|
||||
// opener flag to get wrong — but the lesson stands: a flag that returns null by spec will look
|
||||
// exactly like an environment problem.
|
||||
// ★ IN-PLACE (2026-09-23) — a hidden same-page IFRAME, not a popup window.
|
||||
//
|
||||
// WHY THIS CHANGED, so nobody "restores" the popup as a regression fix: an EARLIER Driver ask
|
||||
// wanted a preview window ("show the poster in your browser to help you print it"), and this
|
||||
// helper was built around window.open() to serve it. The Driver has now REVERSED that: the print
|
||||
// dialog must open DIRECTLY, with no second browser window. That is a decision change, not a bug.
|
||||
//
|
||||
// An iframe is the right vehicle because it keeps the ENTIRE existing contract: callers still pass
|
||||
// one full <html> document string, we still document.write() it, and the markup's own
|
||||
// `<img onload> -> window.print()` still fires — only now `window` inside that document is the
|
||||
// iframe's contentWindow, so the dialog belongs to THIS page. All three print paths (poster /
|
||||
// list / card deck) stay on this one mechanism.
|
||||
//
|
||||
// It also deletes a whole failure mode: there is no popup blocker on an iframe, so the
|
||||
// "popup blocked -> fall back to opening the raw image" branch every caller carried is now dead.
|
||||
// The returned object keeps a truthy `.document`-less shape only so existing `if (!w)` guards stay
|
||||
// harmless; callers should no longer rely on a popup-blocked null.
|
||||
function openPrintWindow(title, bodyHtml, { autoprint = false } = {}) {
|
||||
const w = window.open('', '_blank', 'width=980,height=1100')
|
||||
if (!w) return null // popup blocked — caller decides the fallback
|
||||
w.document.open()
|
||||
w.document.write(bodyHtml)
|
||||
const frame = document.createElement('iframe')
|
||||
// Off-screen rather than display:none — a display:none iframe does not lay out in every engine,
|
||||
// and a zero-height layout can print a blank first page.
|
||||
frame.setAttribute('aria-hidden', 'true')
|
||||
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:0;border:0'
|
||||
document.body.appendChild(frame)
|
||||
|
||||
const doc = frame.contentDocument
|
||||
if (!doc) { frame.remove(); return null } // no document -> caller keeps its fallback
|
||||
doc.open()
|
||||
doc.write(bodyHtml)
|
||||
if (autoprint) {
|
||||
// Appended AFTER the caller's markup so it runs once the document is parsed —
|
||||
// matches the <img onload> timing the poster path already relies on.
|
||||
w.document.write(
|
||||
doc.write(
|
||||
'<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print()},150)})</' + 'script>')
|
||||
}
|
||||
w.document.close()
|
||||
return w
|
||||
doc.close()
|
||||
|
||||
// Reclaim the node once the dialog has been dismissed. `afterprint` is the honest signal; the
|
||||
// timeout is a floor for engines that never fire it, so a click can never leak a frame per press.
|
||||
const drop = () => { if (frame.parentNode) frame.remove() }
|
||||
try { frame.contentWindow.addEventListener('afterprint', () => setTimeout(drop, 0)) } catch { /* cross-doc guard */ }
|
||||
setTimeout(drop, 60000)
|
||||
|
||||
return frame.contentWindow
|
||||
}
|
||||
|
||||
// Escape the handful of characters that break out of an HTML text node or a "..." attribute.
|
||||
|
|
@ -336,20 +367,33 @@ async function printPoster(list) {
|
|||
const title = `Doxa UPG-100 poster — ${escapeHtml(list.label || list.id)}`
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
'<style>@page{size:auto;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
|
||||
// PORTRAIT, explicitly. `size:auto` let the browser choose, and it chooses LANDSCAPE for a
|
||||
// square: the poster art is 3ft x 3ft (aspect-ratio:1/1, see the wall template below), so an
|
||||
// auto page has no reason to prefer the tall axis. The list and card-deck print paths in this
|
||||
// same file already pin `size:letter portrait`; the poster path was the odd one out.
|
||||
// (Driver: "print to a vertical piece of printer paper".)
|
||||
'<style>@page{size:letter portrait;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
|
||||
'body{display:flex;align-items:center;justify-content:center}' +
|
||||
'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}' +
|
||||
'@media print{body{height:auto}img{max-height:none;width:100%}}</style></head>' +
|
||||
// A SQUARE on a PORTRAIT sheet must fit by its SHORTER axis. The old print rule
|
||||
// (`max-height:none;width:100%`) scaled the square to the full page WIDTH, which on portrait
|
||||
// letter is taller than the printable height -> the poster overflowed onto a second page.
|
||||
// Constrain BOTH axes and let object-fit letterbox it inside the sheet instead.
|
||||
'@media print{body{height:auto}' +
|
||||
'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>' +
|
||||
'<body><img src="' + url + '" alt="' + title + '"' +
|
||||
' onload="setTimeout(function(){window.focus();window.print()},150)"' +
|
||||
' onerror="document.body.innerHTML=\'<p style="font:16px system-ui;padding:24px">Poster not available yet.</p>\'">' +
|
||||
'</body></html>')
|
||||
if (!w) { // popup blocked → open the image tab
|
||||
if (!w) {
|
||||
// No longer "popup blocked" — an iframe cannot be blocked. This is now only reachable if the
|
||||
// frame has no contentDocument at all (a sandboxed/detached host), so the honest fallback is
|
||||
// the poster itself rather than a pop-up instruction the user cannot act on.
|
||||
window.open(url, '_blank', 'noopener')
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900); return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'Poster', phase: 'error',
|
||||
|
|
@ -433,11 +477,13 @@ async function printList100(list) {
|
|||
'<th>Religion</th><th></th></tr></thead><tbody>' + rowsHtml + '</tbody></table>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
if (!w) {
|
||||
// Not a pop-up blocker any more (this prints via a same-page iframe). Only a host that
|
||||
// denies us a contentDocument lands here, so say something the user can act on.
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
|
||||
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
|
|
@ -505,11 +551,12 @@ async function printCardDeck(list) {
|
|||
'<div class="deck">' + cardsHtml + '</div>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
if (!w) {
|
||||
// See the note in the list path: an iframe cannot be pop-up blocked.
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
|
||||
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
|
||||
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@
|
|||
* regions-dashboard could reuse it instead of copying it a third time.
|
||||
*
|
||||
* Copied conventions (see layouts/app-wrapper-material/index.html, 'upg-100-list'):
|
||||
* • panes: { id:'map', label:'Map', at:[0,0] } and
|
||||
* { id:'list', label:'Prayer Lists', at:[1,0], config:{ profile:'poster-wall' } }
|
||||
* • panes: { id:'list', label:'Prayer Resources', at:[0,0], config:{ profile:'poster-wall' } }
|
||||
* and { id:'map', label:'Map', at:[1,0] } — RESOURCES FIRST; see the note
|
||||
* on `panes` below for why the familiar view is the on-ramp to the map.
|
||||
* • per-pane element config = { ...spec.config, ...pane.config } — same tag, same
|
||||
* bundle, different `profile`; one download serves both panes.
|
||||
* • ONE shared instanceId for both panes (as the shell does) so the poster wall's
|
||||
|
|
@ -53,9 +54,21 @@ const spec = computed(() => ({
|
|||
id: 'upg-100-mfe',
|
||||
tag: TAG,
|
||||
config: { profile: 'upg-100-list', dataSource: cfg.value?.dataSource || 'pray-tools', instanceId: instanceId.value },
|
||||
// RESOURCES FIRST, MAP SECOND (Driver 2026-09-23). "By default the first tab
|
||||
// shouldn't be the map. The first tab should be the prayer resources" — that pane
|
||||
// already explains what a UPG-100 list IS at the top and shows the posters, so it
|
||||
// needs no explanation; a choropleth of zones does. Generalised by him as a design
|
||||
// principle: "maps don't have to be primary. You can use lists and grids for the
|
||||
// primary view and maps as a secondary view… then they transition into the map."
|
||||
//
|
||||
// ORDER LIVES IN `at[0]`, NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by
|
||||
// at[0] (pane-wrapper.js), while ViewSelect renders this array VERBATIM. Swapping
|
||||
// only one of the two desynchronises the tab strip from the scroll column — the
|
||||
// strip would read "Prayer Resources | Map" while scroll position 0 still held the
|
||||
// map. Both are swapped here, and `view` below is initialised from the same sort.
|
||||
panes: [
|
||||
{ id: 'map', label: 'Map', at: [0, 0], config: { profile: 'upg-100-list' } },
|
||||
{ id: 'list', label: 'Prayer Lists', at: [1, 0], config: { profile: 'poster-wall' } },
|
||||
{ id: 'list', label: 'Prayer Resources', at: [0, 0], config: { profile: 'poster-wall' } },
|
||||
{ id: 'map', label: 'Map', at: [1, 0], config: { profile: 'upg-100-list' } },
|
||||
],
|
||||
// The View switcher lives in this element's own header, so the layout's
|
||||
// "… below ↓" pill would be a second, redundant control (shell design note 2026-08-01).
|
||||
|
|
@ -63,7 +76,24 @@ const spec = computed(() => ({
|
|||
}))
|
||||
|
||||
const stage = ref(null)
|
||||
const view = ref('map')
|
||||
// DERIVED from the pane order, never a hardcoded id. This was `ref('map')`, which
|
||||
// silently became wrong the moment the panes were reordered: the column scrolled to
|
||||
// the resources pane while the strip still painted "Map" as pressed. Sorting by
|
||||
// at[0] here is the SAME rule buildPaneColumn applies, so the pressed tab and the
|
||||
// pane at scroll-position 0 cannot disagree.
|
||||
const firstPaneId = computed(() => {
|
||||
const col = spec.value.panes
|
||||
.filter(p => (p.at?.[1] ?? 0) === 0)
|
||||
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
|
||||
return col[0]?.id || 'list'
|
||||
})
|
||||
const viewRaw = ref(null)
|
||||
// Until the column exists, the active view IS the first pane — so the strip paints a
|
||||
// pressed tab on the very first render rather than flashing with nothing selected.
|
||||
const view = computed({
|
||||
get: () => viewRaw.value || firstPaneId.value,
|
||||
set: (v) => { viewRaw.value = v },
|
||||
})
|
||||
let column = null
|
||||
let offScroll = null
|
||||
|
||||
|
|
|
|||
|
|
@ -45,13 +45,25 @@ import Fuse from '@map/components/search/fuzzy.js'
|
|||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import { countrySlug } from './src/countries.js'
|
||||
// ── DERIVED family axes (BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ──
|
||||
// The Religion/Language trees' TOP TIERS emit DERIVED family names ("Hinduism",
|
||||
// "Indo-European") that exist on no row field, so for months they could narrow the dashboard
|
||||
// but never this grid. The fix is to run the SAME derivation here, from the SAME library
|
||||
// modules the dashboard's config uses -- one owner per derivation, two consumers:
|
||||
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
|
||||
import { canonicalFamilyName } from '@map/colors/language-family.js'
|
||||
import langFamilyByIso from '@map/data/langFamilyByIso.json'
|
||||
|
||||
const props = defineProps({
|
||||
// ── injected standalone deps (replace Nuxt useRuntimeConfig / useI18n / useLocalePath) ──
|
||||
profileConfig: { type: String, default: '' },
|
||||
prayBaseUrl: { type: String, default: '' },
|
||||
languageCode: { type: String, default: '' },
|
||||
researchUrl: { type: String, default: '/research/' },
|
||||
// ★ default '' NOT '/research/': a non-empty prop default is ALWAYS truthy, so the computed
|
||||
// below could never reach its canonical fallback -- the relative default simply moved houses
|
||||
// and kept winning (measured: bundle carried the canonical branch AND default:"/research/",
|
||||
// and every rendered link was still relative). Unset must mean unset. */
|
||||
researchUrl: { type: String, default: '' },
|
||||
selectUrl: { type: String, default: '' },
|
||||
t: { type: Object, default: () => ({}) },
|
||||
|
||||
|
|
@ -179,6 +191,9 @@ const props = defineProps({
|
|||
// a REAL row field, unlike the derived language_family/religion_family facets, which is why it
|
||||
// can move the grid at all.
|
||||
gospelAccess: { type: String, default: '' },
|
||||
// ── the two DERIVED family axes (no row field carries them; see DERIVED_AXES below) ──
|
||||
religionFamily: { type: String, default: '' },
|
||||
languageFamily: { type: String, default: '' },
|
||||
|
||||
// ── presentation knobs copied from UupgsList (defaults preserve today's behaviour) ──
|
||||
perPage: { type: Number, default: 24 },
|
||||
|
|
@ -194,7 +209,18 @@ const cfg = computed(() => {
|
|||
})
|
||||
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
|
||||
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
|
||||
const researchUrl = computed(() => props.researchUrl || cfg.value.researchUrl || '/research/')
|
||||
// ★ ABSOLUTE CANONICAL DEFAULT (Driver 2026-09-23, pasted from his own browser:
|
||||
// http://localhost:5174/research/pitaguari/ -- "when I click view more information it does not
|
||||
// send me to the proper doxa.life page"). The old default '/research/' was ROOT-RELATIVE, so it
|
||||
// resolved against whatever origin happened to host the element: right on doxa.life, wrong on
|
||||
// every other host -- the dev server, an iframe embed, a partner site. Same defect class as the
|
||||
// ../../ child-src bug: a relative default encoding an assumption about the host.
|
||||
// The shared PeopleGroupDetail already solved this exact problem (PeopleGroupDetail.vue:427):
|
||||
// window.DOXA_SITE_URL override, else the absolute canonical origin. Adopting the SAME pattern
|
||||
// here makes the card link and the detail panel agree on where a people group lives -- two
|
||||
// surfaces, one rule. Prop and cfg still take precedence for hosts that want their own base.
|
||||
const researchUrl = computed(() => props.researchUrl || cfg.value.researchUrl
|
||||
|| (((typeof window !== 'undefined' && window.DOXA_SITE_URL) || 'https://doxa.life').replace(/\/+$/, '') + '/research/'))
|
||||
const selectUrl = computed(() => props.selectUrl || cfg.value.selectUrl || '')
|
||||
const t = computed(() => ({ ...(cfg.value.t || {}), ...(props.t || {}) }))
|
||||
|
||||
|
|
@ -254,6 +280,10 @@ const propFilters = computed(() => {
|
|||
// The API-field-name rule a fourth time, for the same reason: the prop is `gospelAccess` and the
|
||||
// row field is `imb_gsec`. Keying on the prop name would read undefined on every row.
|
||||
if (props.gospelAccess) f.imb_gsec = { value: props.gospelAccess, label: lbl(props.gospelAccess) }
|
||||
// DERIVED axes: the key is not a row field but a DERIVED_AXES accessor name -- rowAxisValue()
|
||||
// resolves it, so both filter loops stay on the one shared matcher.
|
||||
if (props.religionFamily) f.__religion_family = { value: props.religionFamily, label: lbl(props.religionFamily) }
|
||||
if (props.languageFamily) f.__language_family = { value: props.languageFamily, label: lbl(props.languageFamily) }
|
||||
return f
|
||||
})
|
||||
const activeFilters = computed(() => (useFilters.value ? localFilters.value : propFilters.value))
|
||||
|
|
@ -356,6 +386,34 @@ function toggleExact() {
|
|||
// vocabulary works and a new value needs no code change. Kept deliberately narrow: it only
|
||||
// widens what counts as equal, so a genuine non-match still filters to nothing.
|
||||
const norm = (s) => String(s ?? '').toLowerCase().replace(/[\s_&-]+/g, '')
|
||||
// ── DERIVED AXES: family values computed per-row, byte-parity with dashboard.config.js ──
|
||||
// religion family = first letter of the religion CODE -> RELIGION_FAMILIES name; language
|
||||
// family = the API's imb_language_family label when present (1.6% of rows), else the
|
||||
// ISO-639-3 lookup via langFamilyByIso (lifts coverage to ~96%), both case-folded through
|
||||
// canonicalFamilyName. Returned as {value,label} so valueMatches() treats a derived axis
|
||||
// exactly like a fetched one.
|
||||
const DERIVED_AXES = {
|
||||
__religion_family: (u) => {
|
||||
const code = u.religion && typeof u.religion === 'object' ? u.religion.value : u.religion
|
||||
const c = String(code || '').charAt(0).toUpperCase()
|
||||
const name = c && RELIGION_FAMILIES[c] ? RELIGION_FAMILIES[c] : ''
|
||||
return name ? { value: name, label: name } : null
|
||||
},
|
||||
__language_family: (u) => {
|
||||
const f = u.imb_language_family
|
||||
const api = f && typeof f === 'object' ? (f.label || f.value) : f
|
||||
let name = ''
|
||||
if (api) name = canonicalFamilyName(api)
|
||||
else {
|
||||
const iso = u.primary_language && typeof u.primary_language === 'object' ? u.primary_language.value : null
|
||||
const hit = iso && langFamilyByIso[iso]
|
||||
name = hit ? canonicalFamilyName(hit) : ''
|
||||
}
|
||||
return name ? { value: name, label: name } : null
|
||||
},
|
||||
}
|
||||
// ONE resolver for both filter loops: a derived key runs its accessor, a real key reads the row.
|
||||
const rowAxisValue = (u, field) => (DERIVED_AXES[field] ? DERIVED_AXES[field](u) : u[field])
|
||||
function valueMatches(vl, selection) {
|
||||
if (!vl) return false
|
||||
const want = norm(selection?.value)
|
||||
|
|
@ -367,7 +425,7 @@ function applyDropdownFilters(list) {
|
|||
let filtered = list
|
||||
for (const [field, selection] of Object.entries(activeFilters.value)) {
|
||||
if (field === 'exact') { filtered = filtered } else {
|
||||
filtered = filtered.filter((u) => valueMatches(u[field], selection))
|
||||
filtered = filtered.filter((u) => valueMatches(rowAxisValue(u, field), selection))
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
|
|
@ -435,7 +493,7 @@ function applyWith(filters, list) {
|
|||
let filtered = list
|
||||
for (const [field, selection] of Object.entries(filters)) {
|
||||
if (field === 'exact') continue
|
||||
filtered = filtered.filter((u) => valueMatches(u[field], selection))
|
||||
filtered = filtered.filter((u) => valueMatches(rowAxisValue(u, field), selection))
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
|
@ -569,7 +627,7 @@ async function getUUPGs() {
|
|||
// primaryLanguage prop match nothing and silently filter the grid to zero rows. Verified live
|
||||
// (2026-09-22): adding it keeps the response at 2,107 rows and the field is populated on 100%
|
||||
// of them. Any future filter axis must be added HERE as well as to propFilters.
|
||||
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion,has_photo,image_url,adopted_by_churches,imb_alternate_name,engagement_status,people_committed,primary_language,imb_reg_of_people_2,imb_reg_of_people_25,imb_gsec&lang=${languageCode.value}`
|
||||
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion,has_photo,image_url,adopted_by_churches,imb_alternate_name,engagement_status,people_committed,primary_language,imb_reg_of_people_2,imb_reg_of_people_25,imb_gsec,imb_language_family&lang=${languageCode.value}`
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
|
|
@ -604,8 +662,14 @@ async function getUUPGs() {
|
|||
}
|
||||
|
||||
// external prop-driven filters should re-filter the grid (when controls are hidden).
|
||||
watch(() => [props.query, props.wagfRegion, props.wagfBlock, props.rop1, props.religion, props.countryCode,
|
||||
props.primaryLanguage, props.engagementStatus, props.cluster, props.peopleGroup, props.gospelAccess,
|
||||
// ★ WATCH THE DERIVED propFilters, NOT a hand-enumerated prop list. This watcher used to spell
|
||||
// out every filter prop by name -- the THIRD copy of the axis list (defineProps, propFilters,
|
||||
// here), and the copy nobody remembers: religionFamily/languageFamily landed in the first two
|
||||
// and the grid received the prop, showed its "Showing" count line, and silently never
|
||||
// re-filtered (measured: Hinduism selected, first card still a Malaysian Islamic group).
|
||||
// propFilters already derives from every axis prop, so watching it makes the axis list have
|
||||
// ONE owner and a future axis cannot miss the watcher.
|
||||
watch(() => [props.query, propFilters.value,
|
||||
useSearch.value, useFilters.value], () => {
|
||||
page.value = 1
|
||||
filterUUPGs()
|
||||
|
|
|
|||
|
|
@ -90,18 +90,12 @@ export default {
|
|||
// family totals sum exactly to the leaf-code totals.
|
||||
religion_family: {
|
||||
label: 'Religion Family',
|
||||
// ★ DASHBOARD-ONLY, AND SAYING SO IS THE FIX. This facet narrows the dashboard correctly but
|
||||
// can never move the grid beneath it, because the value it emits ("Hinduism") is a DERIVED
|
||||
// family name that exists on no row field — the rows carry religion CODES. Mapping it in
|
||||
// dashboard-plus-grid's FACET_TO_PROP would compare "Hinduism" against a code and filter the
|
||||
// grid to ZERO, which is worse than leaving it unmapped.
|
||||
//
|
||||
// Measured 2026-09-23 (interop sweep, 4 rows tested): dashboard 2107 -> 693/649/610/83, grid
|
||||
// unchanged every time. That combination is indistinguishable from a broken button: the row
|
||||
// highlights, the numbers above move, and the list below ignores you. The honest fix is not to
|
||||
// wire it — it is to TELL the user this row drives the dashboard only, which is what this flag
|
||||
// makes the rail render.
|
||||
dashboardOnly: true,
|
||||
// ★ NO LONGER dashboard-only (2026-09-23). This facet's value is DERIVED ("Hinduism" from
|
||||
// the religion code's first letter), which for months meant no grid row field could match
|
||||
// it -- the honest stopgap was a dashboardOnly flag the rail rendered as "updates this
|
||||
// dashboard only". The real fix landed instead: people-groups-grid runs the SAME
|
||||
// derivation (DERIVED_AXES, importing the same RELIGION_FAMILIES map this file reads), so
|
||||
// the family name the bus carries now byte-matches a value the grid computes per row.
|
||||
get: (r) => {
|
||||
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
|
||||
const c = String(code || '').charAt(0).toUpperCase()
|
||||
|
|
@ -155,11 +149,10 @@ export default {
|
|||
// byte-match contract intact.
|
||||
language_family: {
|
||||
label: 'Language Family',
|
||||
// ★ DASHBOARD-ONLY — same class as religion_family, different cause. This value is DERIVED
|
||||
// via langFamilyByIso for ~96% of rows, so it exists on no row field the grid can filter on.
|
||||
// Measured 2026-09-23 (4 rows tested): dashboard 2107 -> 677/306/222/151, grid unchanged.
|
||||
// See the religion_family note above for why declaring it beats wiring it.
|
||||
dashboardOnly: true,
|
||||
// ★ NO LONGER dashboard-only (2026-09-23) — same arc as religion_family above: the value
|
||||
// is DERIVED (imb_language_family label, else langFamilyByIso over the ISO code), and the
|
||||
// grid now runs the SAME derivation from the SAME @map modules (DERIVED_AXES), so the
|
||||
// Language tree's top tier drives the grid like every fetched axis.
|
||||
get: (r) => {
|
||||
const api = lbl(r.imb_language_family)
|
||||
if (api) return canonicalFamilyName(api)
|
||||
|
|
@ -310,7 +303,38 @@ export default {
|
|||
// POINT data", which is exactly this map's job, so this is an alignment, not a new choice.
|
||||
// One line, and it survives the MapLibre swap unchanged.
|
||||
map: {
|
||||
mapStyle: 'paper',
|
||||
// ── PROVIDER: MapLibre ───────────────────────────────────────────────────────────────────
|
||||
// DRIVER 2026-09-22: "the map on the big picture to be MapLibre"; and 2026-09-23 on the
|
||||
// terms — "keep every Mapbox component working so we don't break Mapbox compatibility — we
|
||||
// just want interoperability, so we can come back to Mapbox if this breaks things."
|
||||
//
|
||||
// ★ THIS ONE KEY IS THE WHOLE SWAP, AND THAT IS THE POINT. DashboardMapPanel reads it
|
||||
// (`cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox'`), useMapInstance loads the matching
|
||||
// engine + CSS, and registry.js resolves the style name on whichever engine is live. Delete
|
||||
// this line — or set it to 'mapbox' — and the dashboard is byte-for-byte what it was before,
|
||||
// with the Mapbox token still wired and no other file to restore. Reversibility is not a
|
||||
// claim here; it is the shape of the change. (Proven by flipping it and rebuilding, not
|
||||
// asserted — see the verification note in the commit.)
|
||||
//
|
||||
// WHY IT COSTS NOTHING TO STAY DUAL: MapLibre ignores accessToken rather than rejecting it,
|
||||
// so the Mapbox credential stays threaded through untouched while MapLibre is live.
|
||||
// ★ MAPBOX FOR THIS MAP, BY THE DRIVER'S OWN DESCRIPTION (2026-09-23). Every wording of the
|
||||
// ask pointed at one thing: "like the Mapbox map", "a mirror or clone of Mapbox's globe space
|
||||
// style with the stars", "water like Mapbox style". Mapbox Standard's globe NATIVELY renders
|
||||
// the starfield, the atmosphere and that water -- no clone can beat the original, and we hold
|
||||
// a working baked token (the Driver's explicit ruling accepted it as public). MapLibre support
|
||||
// stays fully built and reversible: flip this one key back and the keyless VersaTiles path
|
||||
// returns. Interoperability was the requirement; it is intact.
|
||||
provider: 'mapbox',
|
||||
|
||||
// 'galaxy' = "deep-space ground, blue oceans" (library/styles/registry.js).
|
||||
// DRIVER 2026-09-23: "the map style needs to have the BLUE WATER with like the BLACK GALAXY
|
||||
// background". It resolves on BOTH engines — MapLibre's keyless OpenFreeMap dark and Mapbox
|
||||
// dark-v11 — each patched to the same blue water at style.load, so flipping `provider` above
|
||||
// does not change how the map LOOKS, only which engine draws it. That is what makes the
|
||||
// reversibility test above meaningful rather than cosmetic.
|
||||
// Was 'paper' (near-white minimal) until this change.
|
||||
mapStyle: 'galaxy',
|
||||
|
||||
// ── HEARTBEAT ZONES ──────────────────────────────────────────────────────────────────────
|
||||
// A HEARTBEAT ZONE is a country holding the most people groups — where the concentration of
|
||||
|
|
@ -371,7 +395,16 @@ export default {
|
|||
|
||||
// ── charts ── (DashboardChartsRail)
|
||||
charts: [
|
||||
{ id: 'religion', type: 'donut', facet: 'religion', title: 'Religion', topN: 8 },
|
||||
// ★ HIGH-LEVEL RELIGIONS ONLY (Driver 2026-09-23: "it should not mention Ethnoreligion and
|
||||
// Islam multiple times, just once -- just the high-level religions, not broken down into
|
||||
// smaller categories"). The rollup ALREADY EXISTED as the religion_family facet ("Islam",
|
||||
// "Hinduism", "Ethnoreligion" -- one row each); the chart was simply pointed at the
|
||||
// fine-grained `religion` facet ("Islam - Sunni", "Islam - Folk", ...). One word moves it.
|
||||
// religion_family is dashboardOnly by design (a derived value no row field carries), so a
|
||||
// slice click narrows the DASHBOARD but not the grid below -- documented behaviour, and for
|
||||
// a glanceable donut that is the right trade. The full breakdown still lives in the facet
|
||||
// rail's Religion tree for anyone who needs Sunni vs Folk.
|
||||
{ id: 'religion', type: 'donut', facet: 'religion_family', title: 'Religion', topN: 8 },
|
||||
{ id: 'region', type: 'donut', facet: 'wagf_region', title: 'Region', topN: 9 },
|
||||
{ id: 'country', type: 'bar', facet: 'country_code', title: 'Top countries', topN: 12 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -180,11 +180,15 @@ const { startFacetResize, startRailResize, startTableResize, startShellResize, o
|
|||
// mapH mirrors the page-scroll CSS fallback 40svh (~360px at a 900px viewport — see
|
||||
// BUG-DASHBOARD-BAND-GROWS-ENORMOUS: 58svh left the default band taller than one viewport;
|
||||
// 40svh is the value that makes chrome(137px) + map(X) + table(X+32px bar) sum to <=900px).
|
||||
// shellH default 720 = the map+table combined budget that mapH:360 used to imply on its own
|
||||
// (360 map + 360 table-with-bar) — see useDashboardLayout.js's DEFAULTS comment for the exact
|
||||
// accounting. Must mirror --rd-shell-h's CSS fallback below for the same reset()-vs-fresh-load
|
||||
// reason as the other three.
|
||||
defaults: { facetW: 296, railW: 264, tableH: '34%', mapH: 360, shellH: 720 },
|
||||
// ★ NO LOCAL defaults OVERRIDE — deliberately. This line used to pass its own copy of the
|
||||
// numbers ({ mapH:360, shellH:720 }), which silently OVERRODE the composable's DEFAULTS via the
|
||||
// { ...DEFAULTS, ...options.defaults } merge. Result, measured 2026-09-23: the composable said
|
||||
// mapH 260 / shellH 502, this line said 360 / 720, the bundle carried BOTH, and this one won --
|
||||
// so two height-reclaim passes changed the shell by exactly nothing while every CSS fallback
|
||||
// moved. The one-quantity-one-owner rule, third costume: a duplicated DEFAULT is still a second
|
||||
// owner. The composable's DEFAULTS (useDashboardLayout.js:98, with the row arithmetic comment)
|
||||
// is the single home; anything this host needs to differ must be a conscious, single-key
|
||||
// override with a reason, never a full mirrored copy that drifts.
|
||||
})
|
||||
|
||||
// KPI strip — generic formatting per kind (mirrors the original inline strip).
|
||||
|
|
@ -223,6 +227,14 @@ useShadowStyles(`
|
|||
_qa-cycle/01-ideation-research/REFERENCE-slim-material-ui-standards.md, REF-slim-material-ui-standards).
|
||||
LIGHT ONLY — there is no dark mode in this product and none may be added.
|
||||
|
||||
★ SCOPE: this law governs UI CHROME — panels, cards, type, borders, controls; everything
|
||||
whose colour comes from a token above. It does NOT govern BASEMAP TILES. The map panel
|
||||
renders a dark basemap ('galaxy' — black ground, blue water, set in dashboard.config.js on
|
||||
the Driver's 2026-09-23 ruling), and that is DELIBERATE, not drift: a basemap is imagery the
|
||||
data is drawn ON, chosen for choropleth contrast, not a surface the UI is themed with. A
|
||||
dark map inside light chrome is the intended composition. Do not "fix" it to match the
|
||||
palette, and do not cite it as precedent for darkening any chrome.
|
||||
|
||||
--dx-scroll-thumb is a DELIBERATE neutral, not a sage: it must read between line and muted
|
||||
on both white and surface, which a sage tint does not do. It is named, therefore chosen. */
|
||||
:host{ display:block; width:100%; height:100%; min-height:560px; box-sizing:border-box;
|
||||
|
|
@ -244,7 +256,10 @@ useShadowStyles(`
|
|||
ONE line, while the charts carry a donut plus a legend that can wrap.
|
||||
So the widths SWAP ROLES: the tree takes the wider column, the charts the narrower.
|
||||
The pair still sums to 560px, so the centre map cell is unchanged. */
|
||||
--facet-w:296px; --rail-w:264px;
|
||||
/* ★ SYMMETRIC SIDEBARS (Driver 2026-09-23: "the charts sidebar can be wider so it is the
|
||||
same width as the left sidebar, which will put the map in the exact center"). rail
|
||||
264 -> 296 = facet width; equal flanks centre the middle column by construction. */
|
||||
--facet-w:296px; --rail-w:296px;
|
||||
/* ── ★ ONE EDGE GUTTER FOR THE WHOLE HEADER STACK ────────────────────────────────────
|
||||
(card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK: "on the edges there should be padding
|
||||
so that everything lines up perfectly.")
|
||||
|
|
@ -272,14 +287,14 @@ useShadowStyles(`
|
|||
failure mode --rd-edge was introduced to prevent for the horizontal gutter. */
|
||||
/* ★ HEIGHT RECLAIM 2026-09-23: topbar 52->44, stats 30->26. Both are now VARS so a host can
|
||||
tune them without editing three grid-template-rows declarations that must agree. */
|
||||
--rd-topbar-h:44px;
|
||||
--rd-stats-h:26px;
|
||||
--rd-topbar-h:38px;
|
||||
--rd-stats-h:22px;
|
||||
--fs-xs:10px; --fs-sm:11px; --fs-md:12px; --fs-lg:13px; --fs-xl:16px;
|
||||
--r-lg:10px; --r-md:6px; --r-pill:999px; }
|
||||
.profile-loader{ width:100%; height:100%; }
|
||||
.rd-shell{ position:relative; display:grid; width:100%; height:100%; min-height:560px; box-sizing:border-box;
|
||||
grid-template-columns: var(--facet-w,296px) minmax(0,1fr) var(--rail-w,264px);
|
||||
grid-template-rows: var(--rd-topbar-h,44px) auto var(--rd-stats-h,26px) 1fr var(--table-h,34%);
|
||||
grid-template-columns: var(--facet-w,296px) minmax(0,1fr) var(--rail-w,296px);
|
||||
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) 1fr var(--table-h,34%);
|
||||
grid-template-areas: "topbar topbar topbar" "filters filters filters" "stats stats stats" "facets map rail" "facets table rail";
|
||||
gap:1px; background:var(--rd-line, #d1d4cd); container-type:inline-size;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
|
|
@ -297,11 +312,11 @@ useShadowStyles(`
|
|||
still showing the live matched count and the Export CSV button (card
|
||||
BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS). The row shrinks to the bar's height
|
||||
instead of 0, so the map still takes back nearly the whole cell via its 1fr. */
|
||||
.rd-shell.rd-table-collapsed{ grid-template-rows:var(--rd-topbar-h,44px) auto var(--rd-stats-h,26px) 1fr var(--table-bar-h,30px); }
|
||||
.rd-shell.rd-table-collapsed{ grid-template-rows:var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) 1fr var(--table-bar-h,30px); }
|
||||
/* Facet/rail collapse — override the COLUMN template (not the --*-w vars) so collapse wins
|
||||
over a dragged inline width, exactly like the table row-template above. Map is minmax(0,1fr)
|
||||
and self-resizes via DashboardMapPanel's ResizeObserver → map.resize(). */
|
||||
.rd-shell.rd-facet-collapsed{ grid-template-columns:0 minmax(0,1fr) var(--rail-w,264px); }
|
||||
.rd-shell.rd-facet-collapsed{ grid-template-columns:0 minmax(0,1fr) var(--rail-w,296px); }
|
||||
.rd-shell.rd-rail-collapsed{ grid-template-columns:var(--facet-w,296px) minmax(0,1fr) 0; }
|
||||
.rd-shell.rd-facet-collapsed.rd-rail-collapsed{ grid-template-columns:0 minmax(0,1fr) 0; }
|
||||
/* Always-reachable edge carets pinned to the map, toggle each rail (desktop only). */
|
||||
|
|
@ -388,8 +403,60 @@ useShadowStyles(`
|
|||
shell budget). Every derived measurement below (table budget, facets/rail
|
||||
span) now roots in --rd-shell-h so the total stays the single source of
|
||||
truth and the two handles cannot fight over the same pixels. */
|
||||
.rd-shell.rd-page-scroll{ height:auto; --table-h:auto; }
|
||||
.rd-shell.rd-page-scroll > [data-region="map"]{ height:var(--rd-map-h, 40svh); overflow:hidden; }
|
||||
/* ★ THE SHELL TOTAL NOW DERIVES FROM THE TABLE, NOT THE OTHER WAY ROUND (regression I introduced
|
||||
in a693865 and the Driver caught immediately: "the height of the table is smaller now, but that
|
||||
introduced a GAP, a grey spot ... which also broke the ability to resize the table").
|
||||
WHAT I BROKE. The design was one budget with two consumers: --rd-shell-h was the total, and the
|
||||
table's keyhole was DERIVED as shell - map - bar - foot. Capping the keyhole at 7 rows replaced
|
||||
that derivation with an independent constant, so the table stopped answering to the shell. The
|
||||
shell kept asserting its old ~720px total while the table gave back 118px, and since nothing
|
||||
else consumed the slack it showed up as dead space — the grey spot. The resize handle broke for
|
||||
the same reason: it writes --rd-shell-h, which the table no longer reads, so dragging moved a
|
||||
number that nothing downstream obeyed.
|
||||
THE FIX IS TO RESTORE ONE SOURCE OF TRUTH, but pointing the other way: the TABLE's seven rows
|
||||
are now the declared quantity and --rd-shell-h is COMPUTED from it (map + bar + rows + foot).
|
||||
Both handles keep working because both still write inputs to that sum: the map/table divider
|
||||
writes --rd-map-h, and the shell handle writes --rd-shell-h, which overrides this default.
|
||||
★ The var indirection matters: an explicit --rd-shell-h from the drag still wins, so this is a
|
||||
DEFAULT, not a lock. That is what keeps the resize alive rather than pinning the element. */
|
||||
/* ★ THE MAP ROW MUST NOT BE 1fr IN PAGE-SCROLL — this is the grey gap, found by measurement.
|
||||
The base grid (line 290) sets the map row to 1fr, which is right in FIXED-VIEWPORT: the shell
|
||||
has a real bounded height and 1fr means "take what is left". In PAGE-SCROLL the shell is
|
||||
height:auto, so there is no fixed total to divide — 1fr instead becomes "stretch to whatever
|
||||
the content implies", and the map ELEMENT is separately pinned to --rd-map-h. The row grows,
|
||||
the element does not, and the difference paints as dead space between the map and the table.
|
||||
MEASURED in a fresh context: shell 849 while its parts sum to 602 — a 117px gap, exactly the
|
||||
"grey spot between the height of the map" the Driver reported twice.
|
||||
Pinning the row to the SAME var the element uses makes row and element one quantity again, so
|
||||
the slack cannot exist. Both drags keep working because both write that var (or --rd-shell-h,
|
||||
which the table's keyhole below derives from). */
|
||||
/* ★ THE GREY GAP BETWEEN MAP AND TABLE = THE 1fr ROW STRETCHING PAST ITS PINNED OCCUPANT.
|
||||
The base grid gives the map row 1fr, which is correct in fixed-viewport (a real bounded total
|
||||
to divide) and wrong in page-scroll, where the shell is height:auto: 1fr then stretches to
|
||||
whatever the content implies while the map ELEMENT stays pinned at --rd-map-h. The row grows,
|
||||
the element does not, and the difference paints as dead space above the table — reported by
|
||||
the Driver three separate times. Pinning the ROW to the same var the ELEMENT uses makes them
|
||||
one quantity, so the slack cannot exist; the table row goes auto for the same reason.
|
||||
★ HISTORY, so this does not get reverted on a false signal again: this exact rule was applied
|
||||
earlier today and reverted when a probe reported "no shell" — the real cause was a DEAD DEV
|
||||
SERVER, not this CSS. Verified this time against a live server with a screenshot. */
|
||||
.rd-shell.rd-page-scroll{ height:auto; --table-h:auto;
|
||||
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) var(--rd-map-h,340px) auto; }
|
||||
/* ★ COLLAPSE GIVES ITS SPACE TO THE MAP (Driver 2026-09-23: "collapsing the table is supposed to
|
||||
collapse down and increase the size of the map -- instead it collapsed up and introduced white
|
||||
space under the collapsed table"). The base .rd-table-collapsed template still says 1fr for the
|
||||
map row, and 1fr cannot grow in an auto-height shell -- there is no fixed total to divide -- so
|
||||
the freed space pooled under the bar as white nothing. In page-scroll the collapsed map takes
|
||||
the SAME shell budget the open state uses, minus only the bar: the one-owner derivation again,
|
||||
so collapse/expand and both drag handles all move the same quantity instead of fighting.
|
||||
Both the ROW and the ELEMENT get the height, for the same reason they share --rd-map-h when
|
||||
open: two owners of one height is the bug this file keeps re-learning. */
|
||||
.rd-shell.rd-page-scroll.rd-table-collapsed{
|
||||
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px)
|
||||
calc(var(--rd-shell-h, 562px) - var(--table-bar-h,30px)) var(--table-bar-h,30px); }
|
||||
.rd-shell.rd-page-scroll.rd-table-collapsed > [data-region="map"]{
|
||||
height:calc(var(--rd-shell-h, 562px) - var(--table-bar-h,30px)); }
|
||||
.rd-shell.rd-page-scroll > [data-region="map"]{ height:var(--rd-map-h, 340px); overflow:hidden; }
|
||||
.rd-shell.rd-page-scroll > [data-region="table"]{ overflow:visible; }
|
||||
.rd-shell.rd-page-scroll .rd-table-wrap{ height:auto; }
|
||||
/* ★ THE TABLE KEEPS AN INTERNAL SCROLL, BUT A GENEROUS ONE — and that is the whole fix.
|
||||
|
|
@ -456,7 +523,7 @@ useShadowStyles(`
|
|||
GOAL CONTEXT: "we are trying to RECLAIM HEIGHT." 7 rows is 182px against today's 300px keyhole,
|
||||
so this alone returns 118px of the ~147px available. */
|
||||
.rd-shell.rd-page-scroll .rd-grid{
|
||||
max-height:var(--rd-table-max-h, calc(var(--rd-table-rows, 7) * var(--rd-table-row-h, 26px))); }
|
||||
max-height:var(--rd-table-max-h, calc(var(--rd-shell-h, 562px) - var(--rd-map-h, 340px) - var(--rd-table-bar-h, 32px) - var(--rd-table-foot-h, 28px))); }
|
||||
/* ── FACETS/RAIL COLUMNS BOUNDED IN PAGE-SCROLL MODE (BUG-DASHBOARD-BAND-GROWS-ENORMOUS) ──────
|
||||
THE ROOT CAUSE, one level up from the table rule above. .rd-facets/.rd-rail are authored
|
||||
height:100%, which is correct in the FIXED-VIEWPORT grid (rows sum to a real, bounded
|
||||
|
|
@ -533,7 +600,14 @@ useShadowStyles(`
|
|||
NUMBER the cap computes was wrong, not the need for a cap at all. */
|
||||
.rd-shell.rd-page-scroll > [data-region="facets"],
|
||||
.rd-shell.rd-page-scroll > [data-region="rail"]{ min-height:0; overflow:hidden;
|
||||
max-height:calc(var(--rd-shell-h, 80svh) + 1px); }
|
||||
/* ★ FALLBACK UNIFIED AT 502px (was 80svh = 720px). Two rules read --rd-shell-h and their
|
||||
FALLBACKS disagreed: the table keyhole fell back to 502px (seven rows) while this cap fell
|
||||
back to 720px. Nothing sets the var at rest, so the sidebars believed the shell was 119px
|
||||
taller than the table did -- the spanning columns stretched the auto table row to fit, and
|
||||
the surplus painted as the grey band the Driver reported between/under map and table, and
|
||||
made a divider drag look broken (the row would not follow, the slack just moved).
|
||||
ONE QUANTITY, ONE OWNER applies to fallbacks too: same var, same fallback, everywhere. */
|
||||
max-height:calc(var(--rd-shell-h, 562px) + 1px); }
|
||||
|
||||
/* ★ THE ELEMENT MUST SIZE TO ITS CONTENT IN PAGE-SCROLL (Driver 2026-09-23: "massive grey gap
|
||||
between map and table and the map overflows into the people-group grid page — the dashboard
|
||||
|
|
@ -561,7 +635,7 @@ useShadowStyles(`
|
|||
UNPROVEN because no host has yet been measured before-and-after through this path. */
|
||||
:host([data-page-scroll="true"]){ height:auto; min-height:0; }
|
||||
|
||||
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:var(--rd-topbar-h,44px) auto var(--rd-stats-h,26px) minmax(0,1fr) 46px;
|
||||
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) minmax(0,1fr) 46px;
|
||||
grid-template-areas:"topbar" "filters" "stats" "body" "seg"; }
|
||||
.rd-shell.rd-narrow > [data-region]{ grid-area:body; min-width:0; min-height:0; }
|
||||
.rd-shell.rd-narrow > [data-region="topbar"]{ grid-area:topbar; }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import { inject, computed, ref } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { aggregate, buildDonut, buildBars, lbl, buildCountryRegionColors } from '../../composables/useDashboardAggregations.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
|
||||
|
||||
/**
|
||||
* ── THE RAIL'S TAB STRIP ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -55,7 +55,8 @@ function rowsExcluding(exclude) {
|
|||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
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 faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
}
|
||||
|
||||
// COUNTRY -> REGION hue (the Driver's "countries should match the colour of their regions").
|
||||
|
|
@ -190,7 +191,7 @@ useShadowStyles(`
|
|||
restore white-space:nowrap + ellipsis here, and do not re-cap this to the donut's height:
|
||||
that cap is precisely what forced the unreadable ellipsis the Driver rejected. */
|
||||
.rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:var(--dx-text, #3b463d); max-height:120px;
|
||||
overflow-y:auto; overflow-x:auto; flex:1 1 auto; min-width:0;
|
||||
overflow-y:auto; overflow-x:hidden; flex:1 1 auto; min-width:0;
|
||||
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
|
||||
.rd-legend::-webkit-scrollbar{ width:6px; height:6px; }
|
||||
.rd-legend::-webkit-scrollbar-track{ background:transparent; }
|
||||
|
|
@ -200,11 +201,46 @@ useShadowStyles(`
|
|||
now two lines tall, and centring would float the swatch and the count in the middle of that
|
||||
block, detached from the first line of text they label. The count is top-aligned with a 1px
|
||||
nudge (.rd-li b) so it reads as belonging to the row's first line. */
|
||||
.rd-li{ display:flex; align-items:flex-start; gap:5px; cursor:pointer; padding:1px 2px; border-radius:3px; }
|
||||
/* ★ THE CONVENTIONAL LEGEND ROW, on the Driver's final ruling (2026-09-23): "I am not
|
||||
impressed with that design -- use conventional designs only", after rejecting both the
|
||||
scroll-to-read variant ("I cannot scroll right") and the frozen column's painted backing
|
||||
("super white background, it looks odd; it was to be invisible"). This is the pattern every
|
||||
chart library ships: swatch, label that ELLIPSIZES at the available width, count right-
|
||||
aligned in a fixed-width slot. No horizontal scroll, no sticky, no background -- the number
|
||||
needs no backing because nothing can ever slide beneath it. The full label lives in the
|
||||
row's existing :title tooltip. Rows stretch to the container, so every count shares one
|
||||
right edge by ordinary flex geometry. */
|
||||
.rd-li{ display:flex; align-items:center; gap:5px; cursor:pointer; padding:1px 2px; border-radius:3px; }
|
||||
.rd-li > i{ flex:0 0 auto; }
|
||||
.rd-li .rd-lt{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } /* max-content: the row is as wide as its label, so the scrollport has something to scroll */
|
||||
.rd-li:hover{ background:var(--dx-surface, #f3f3f1); } .rd-li.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
|
||||
/* margin-top nudges keep the swatch and the count optically on the label's FIRST line now that
|
||||
a row can be two lines tall. */
|
||||
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; margin-top:3px; } .rd-li b{ margin-left:auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; flex:0 0 auto; padding-left:4px; margin-top:1px; }
|
||||
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; margin-top:3px; }
|
||||
/* ★ THE COUNT IS A FROZEN COLUMN (Driver 2026-09-23): "the number column was supposed to be
|
||||
FROZEN with the z-index topped with a WHITE BACKGROUND so that the text doesn't underlap it, so
|
||||
that whenever you scroll to the right you can read the text but the numbers stay visible."
|
||||
This is the other half of the horizontal-scroll change and shipping without it was incomplete:
|
||||
nowrap + overflow-x:auto made the long label reachable, but the label then slides UNDER the
|
||||
count and the two overlap mid-scroll. A scrollable row needs a pinned column, or the thing you
|
||||
scrolled to read collides with the thing you were reading it against.
|
||||
position:sticky right:0 pins it to the SCROLLPORT's right edge, so it holds while the label
|
||||
travels beneath it. The opaque background is load-bearing, not decoration — without it the
|
||||
label shows THROUGH the number and both become unreadable, which is worse than clipping.
|
||||
Background is --rd-panel (the card's own surface) so the pin matches its host rather than
|
||||
assuming white; the card is #fff today but a host that re-skins the panel should not get a
|
||||
white rectangle floating on a tinted card. */
|
||||
/* ★ THE COUNT COLUMN IS UNIFORM, RIGHT-JUSTIFIED AND IMMOVABLE (Driver 2026-09-23: "set the
|
||||
number column to be justified right, same width, uniform, immovable... so it is easier to
|
||||
use -- and the same goes for the region chart"). One fixed width for every row means the
|
||||
digits align into a scannable column instead of each number sitting wherever its label ended;
|
||||
right-justified plus the existing tabular-nums makes magnitudes comparable at a glance (541
|
||||
visibly longer than 72). 3.4em clears the widest live value (1,309 in the region chart) with
|
||||
a little air. sticky right:0 (from the frozen-column fix) is what makes it immovable while the
|
||||
label scrolls beneath. Both donut legends share .rd-li, so religion and region get this from
|
||||
the one rule -- which is exactly why it lives here and not per-chart. */
|
||||
.rd-li b{ flex:0 0 auto; min-width:3.4em; text-align:right;
|
||||
color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; }
|
||||
/* The label is the ONLY item allowed to shrink. It used to be a bare text node, which in a
|
||||
nowrap flex row cannot shrink at all: the row grew past .rd-legend (overflow-x:hidden) and
|
||||
carried the VALUE out of sight — "Ethnoreligion - Chinese Folk Religion" rendered with no
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
*/
|
||||
import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
|
||||
|
|
@ -104,7 +104,8 @@ const filtered = computed(() => {
|
|||
const keys = Object.keys(filterSet)
|
||||
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))
|
||||
const faceted = allRows.value.filter((r) => keys.every((k) => matchFacet(r, k, filterSet[k])))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -469,7 +470,42 @@ function toggleDock() { collapsed.value = !collapsed.value; bus && bus.emit('tog
|
|||
* actually delivered to a host page.
|
||||
*/
|
||||
const isFull = ref(false)
|
||||
const wrapEl = ref(null)
|
||||
// ★ NATIVE FULLSCREEN, NOT A FIXED OVERLAY (Driver 2026-09-23: "there is no way to exit full
|
||||
// screen or export csv when you enter full screen"). The bar with BOTH buttons is inside this
|
||||
// wrap, so structurally they should have been visible -- but position:fixed inside a shadow root
|
||||
// resolves against any transformed/contained ANCESTOR, not the viewport, and in the cap-5 shell
|
||||
// that pinned the "fullscreen" wrap to a pane whose top sat above the visible area: the bar (and
|
||||
// its Exit + Export CSV) rendered off-screen. The leaf host has no such ancestor, which is why
|
||||
// every leaf measurement passed while the shell was broken -- the two-host rule again.
|
||||
// element.requestFullscreen() is the wheel the platform ships: the browser guarantees viewport
|
||||
// coverage regardless of ancestor transforms, supplies ESC natively, and fires fullscreenchange
|
||||
// so our button state can follow EVERY exit path (ESC, F11, another element taking fullscreen)
|
||||
// instead of only our own toggle. The .is-full class stays as the styling hook and as the
|
||||
// fallback when the API is unavailable (iframes without allowfullscreen).
|
||||
function _syncFull() {
|
||||
// ★ SHADOW BOUNDARY: document.fullscreenElement retargets to the outermost shadow HOST, and
|
||||
// host.contains(innerNode) is false across the boundary -- so the naive check reported "not
|
||||
// fullscreen" while the browser was visibly fullscreen (measured: fullscreenElement had the
|
||||
// custom element's empty className, and the button state never flipped). The real element is on
|
||||
// the wrap's OWN root: getRootNode().fullscreenElement pierces exactly one level, which is where
|
||||
// this wrap lives.
|
||||
const w = wrapEl.value
|
||||
const root = w && w.getRootNode ? w.getRootNode() : document
|
||||
const fsEl = (root && root.fullscreenElement) || document.fullscreenElement
|
||||
isFull.value = !!(fsEl && w && (fsEl === w || (fsEl.contains && fsEl.contains(w))))
|
||||
}
|
||||
if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', _syncFull)
|
||||
onBeforeUnmount(() => { if (typeof document !== 'undefined') document.removeEventListener('fullscreenchange', _syncFull) })
|
||||
function toggleFull() {
|
||||
const el = wrapEl.value
|
||||
if (el && el.requestFullscreen) {
|
||||
if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}) }
|
||||
else { el.requestFullscreen().then(_syncFull).catch(() => { isFull.value = true }) } // fallback: the old overlay
|
||||
// isFull follows via fullscreenchange; the collapsed-expand below still applies either way
|
||||
if (!isFull.value && collapsed.value) { collapsed.value = false; bus && bus.emit('toggleTable', false) }
|
||||
return
|
||||
}
|
||||
isFull.value = !isFull.value
|
||||
// Entering while collapsed would show a fullscreen HEADER BAR and nothing else. The user
|
||||
// asked for the table, so expand it — and tell the shell, which owns the grid row, or the
|
||||
|
|
@ -480,7 +516,14 @@ function toggleFull() {
|
|||
}
|
||||
// Esc is the fullscreen contract every user already knows; without it a pseudo-fullscreen
|
||||
// overlay is a trap, since there is no browser-supplied exit the way the native API gives one.
|
||||
function onKeydown(e) { if (e.key === 'Escape' && isFull.value) { isFull.value = false } }
|
||||
function onKeydown(e) {
|
||||
if (e.key !== 'Escape') return
|
||||
// Native fullscreen: delegate to the API so EVERY consumer of fullscreenchange stays in sync.
|
||||
// (A real user's ESC is handled by the browser itself; this path serves tests and the overlay
|
||||
// fallback, where no browser exit exists and a trapped user was the original defect.)
|
||||
if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}) ; return }
|
||||
if (isFull.value) isFull.value = false
|
||||
}
|
||||
|
||||
// ── footer aggregation ──
|
||||
const sumPop = computed(() => filtered.value.reduce((s, r) => s + (num(r.population) || 0), 0))
|
||||
|
|
@ -560,6 +603,24 @@ useShadowStyles(`
|
|||
pointer-events:none; color:var(--dx-muted, #6d766e); font-size:12px; }
|
||||
/* Hidden where the clusters would collide with it — at narrow widths there is no free middle. */
|
||||
@media (max-width: 1100px){ .rd-bar-hint{ display:none; } }
|
||||
/* ★ THE BAR IS A SELECTION TOOLBAR WHILE A SELECTION EXISTS (the 1280px flood, measured: with
|
||||
one row selected the bar wrapped to 43px and pushed a control out of it -- the Driver's
|
||||
"flooded with buttons that overflow"). The centred hint and the selection cluster cannot both
|
||||
fit a 666px middle column, and the hint is the one carrying redundant information there: its
|
||||
count already lives in the selection pill. It yields exactly while a selection exists and
|
||||
returns the moment the selection clears -- state-scoped, so the resting bar is unchanged.
|
||||
(The 1100px media rule above is the same idea keyed on width; this one is keyed on state.) */
|
||||
.rd-table-wrap:not([data-sel-count="0"]) .rd-bar-hint{ display:none; }
|
||||
/* ★ MEASURED AT 1280 (the Driver's flood, second cause): hiding the hint was not enough -- the
|
||||
in-flow title and count are separate children, and squeezed to 48px the title wrapped
|
||||
INTERNALLY ("People"/"Groups" on two lines), growing the bar to 43px and tumbling the spacer.
|
||||
While a selection exists they say nothing the bar does not already say -- the pill carries the
|
||||
selection count and the composite's own section header above the table already reads "People
|
||||
Groups" -- so they yield with the hint and return on clear. After: every child on one 32px
|
||||
line at 1280, zero overflow. */
|
||||
.rd-table-wrap:not([data-sel-count="0"]) .ttl,
|
||||
.rd-table-wrap:not([data-sel-count="0"]) .ct{ display:none; }
|
||||
.ttl{ white-space:nowrap; } /* and the title may never two-line itself, selection or not */
|
||||
/* Export CSV — lives on THIS bar now (moved from the dashboard topbar). Material-light DX palette:
|
||||
primary #92b195 surface, deep ink label, border #d1d4cd. Stays visible while collapsed. */
|
||||
.rd-table-bar .rd-csv{ display:inline-flex; align-items:center; gap:6px; flex:0 0 auto; cursor:pointer; user-select:none;
|
||||
|
|
@ -675,6 +736,8 @@ useShadowStyles(`
|
|||
position:fixed takes the wrapper out of the shell's grid row without moving it in the TREE,
|
||||
so every inherited token and every ancestor rule still applies and we override only what we
|
||||
mean to. z-index sits just under the max a browser honours, above the map's own controls. */
|
||||
.rd-table-wrap:fullscreen{ background:var(--dx-elevated,#fff); overflow:auto; }
|
||||
.rd-table-wrap:fullscreen .rd-grid{ max-height:none !important; }
|
||||
.rd-table-wrap.is-full{ position:fixed; top:0; left:0; right:0; bottom:0; z-index:2147483646;
|
||||
height:100dvh; width:100vw; box-shadow:0 0 0 100vmax rgba(59,70,61,.22); }
|
||||
/* ★ THE OVERRIDE THAT ACTUALLY DOES THE WORK. The shell caps this element at seven rows via
|
||||
|
|
@ -708,7 +771,7 @@ useShadowStyles(`
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-table-wrap" :class="{ 'is-full': isFull }" :data-sel-count="selCount"
|
||||
<div ref="wrapEl" class="rd-table-wrap" :class="{ 'is-full': isFull }" :data-sel-count="selCount"
|
||||
:data-fullscreen="isFull ? '1' : '0'">
|
||||
<!-- Header bar — ALWAYS rendered, collapsed or not. The count + Export CSV are the two
|
||||
controls the Driver asked to keep docked under the map when the grid is hidden. -->
|
||||
|
|
@ -718,9 +781,18 @@ useShadowStyles(`
|
|||
<span class="ttl">People Groups</span>
|
||||
<span class="ct" data-table-count>{{ fmt(rowCount) }} {{ rowCount === 1 ? 'group' : 'groups' }}</span>
|
||||
<!-- role=status so assistive tech announces the count as the selection changes. -->
|
||||
<!-- ★ NO CLEAR BUTTON IN THIS BAR (Driver 2026-09-23, on the selection flood: "it does not
|
||||
need to give me a Clear Selected button in the header because the FOOTER of the page
|
||||
already gives me that"). One action, one home; this pill is a status readout only.
|
||||
★ VERIFIED WHAT ACTUALLY CLEARS, rather than repeating the premise: no literal footer
|
||||
clear exists in THIS component -- the surviving clear paths are the thead master
|
||||
checkbox (un-checking it deselects all rows) and the detail drawer's close
|
||||
(DashboardDetailDrawer.vue:104 calls bus.clearSelection()). Both remain, so removing
|
||||
the header x removes a DUPLICATE, not the capability. clearSel() stays defined for the
|
||||
bus and any future caller. This also deletes the one control in the cluster that read
|
||||
as "a button that doesn't make sense" -- a bare x with no label. -->
|
||||
<span v-if="selCount" class="rd-pill" data-sel-pill role="status">
|
||||
<span>{{ fmt(selCount) }} selected</span>
|
||||
<button title="Clear selection" aria-label="Clear selection" @click="clearSel">✕</button>
|
||||
</span>
|
||||
<!-- Selection VIEWS — separated from the pill because they mutate the VIEW, not the
|
||||
selection. Only meaningful once something is selected, so they appear with it. -->
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
import { inject, ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { aggregate, lbl } from '../../composables/useDashboardAggregations.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
|
||||
import { useTreeLegendColors } from '../../composables/useTreeLegendColors.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
|
||||
|
|
@ -58,7 +58,8 @@ function rowsExcluding(exclude) {
|
|||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
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 faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
}
|
||||
|
||||
// ── SECTION COLLAPSE STATE (BUG-DASHBOARD-BAND-GROWS-ENORMOUS-WITH-LIVE-DATA) ────────────────
|
||||
|
|
@ -480,7 +481,15 @@ useShadowStyles(`
|
|||
.rd-facets .fr-body::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
|
||||
.rd-facets .fr-body::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
|
||||
.fr-sec{ border-bottom:1px solid var(--rd-line, #d1d4cd); }
|
||||
.fr-sech{ display:flex; align-items:center; gap:6px; padding:5px 12px; cursor:pointer; user-select:none;
|
||||
/* ★ STICKY SECTION HEADERS, GITHUB-MARKDOWN STYLE (Driver 2026-09-23: "when I am scrolling
|
||||
down it needs to keep the section headers visible until you scroll into the next section --
|
||||
like markdown files"). The conventional mechanism does all of it natively: position:sticky
|
||||
top:0 inside the scrolling .fr-body pins the current header, and the NEXT header pushes the
|
||||
previous one away as it arrives, because two sticky siblings at the same top contend for the
|
||||
slot. The opaque background is required -- rows scroll UNDER the pinned header, and sticky
|
||||
over transparent paints the rows through the label. z-index keeps it above row hover states. */
|
||||
.fr-sech{ position:sticky; top:0; z-index:2; background:var(--rd-panel,#fff);
|
||||
display:flex; align-items:center; gap:6px; padding:5px 12px; cursor:pointer; user-select:none;
|
||||
font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted, #6d766e); }
|
||||
.fr-sech:hover{ background:var(--rd-bg, #f3f3f1); }
|
||||
.fr-sech .chev{ margin-left:auto; transition:transform .15s; font-size:10px; color:var(--dx-muted, #6d766e); }
|
||||
|
|
@ -680,11 +689,15 @@ useShadowStyles(`
|
|||
<template>
|
||||
<aside class="rd-facets" data-region="facets">
|
||||
<div class="fr-head">
|
||||
<!-- "Tree Legend" — the Driver's naming ruling: the word "semantic" is dropped from the
|
||||
<!-- "Vertical Legend" — the Driver's naming ruling (2026-09-23): "it shouldn't say tree
|
||||
legend... rename the component to be called Vertical... use the term nesting or
|
||||
folders". The visible title says VERTICAL; the drill-down vocabulary in code and
|
||||
comments is NESTING/FOLDERS, not tree/semantic. Earlier ruling, kept: -->
|
||||
<!-- (prior note) the word "semantic" is dropped from the
|
||||
user-facing label (the component/docs still describe the semantic-tree MODEL).
|
||||
The four stacked trees below it are People · Place · Language · Religion, in that
|
||||
order — the four axes a researcher navigates to find who has gospel resources. -->
|
||||
<div class="fr-title">Tree Legend</div>
|
||||
<div class="fr-title">Vertical Legend</div>
|
||||
<div class="fr-scope" data-scope-count><b>{{ scopeCount.toLocaleString('en-US') }}</b> groups in scope</div>
|
||||
</div>
|
||||
<div class="fr-body" ref="railEl" @click="onRowClick" @keydown="onRowKey">
|
||||
|
|
|
|||
|
|
@ -22,18 +22,24 @@ 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'
|
||||
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
|
||||
// ── THE PRAYER COLOUR STRATEGY, REUSED NOT RE-DERIVED ──
|
||||
// applyColor() returns the SAME 3-tier expression the prayer map paints with, keyed off the
|
||||
// FULL_PRAYER_THRESHOLD that library/colors/prayer-progress.js owns. Importing it here means the
|
||||
// dot a prayer filter leaves on the map can never disagree with the level the filter selected —
|
||||
// one threshold, one owner. (The dashboard facet already reuses getPrayerLevel for the same reason.)
|
||||
import { applyColor as prayerColorExpr } from '@map/colors/prayer-progress.js'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
const mapboxToken = inject('mapboxToken', ref(''))
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
// The host's parsed profile-config, used ONLY to resolve the assets base for the keyless country
|
||||
// boundaries on the MapLibre path (see addLayers). Same channel every other plugin already reads;
|
||||
// resolveAssetsBase degrades correctly when it is null, so a host that provides nothing still
|
||||
// resolves the tree from this bundle's own script URL.
|
||||
const profileConfig = inject('profileConfig', ref(null))
|
||||
const filterSet = bus?.filterSet || {}
|
||||
|
||||
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
|
||||
|
|
@ -44,7 +50,8 @@ const filteredRows = computed(() => {
|
|||
const keys = Object.keys(filterSet)
|
||||
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 faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
})
|
||||
// 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').
|
||||
|
|
@ -86,11 +93,30 @@ const mapContainer = ref(null)
|
|||
// ★ THE TOKEN IS NOT DROPPED WHEN provider='maplibre'. accessToken stays wired: MapLibre ignores
|
||||
// it, and keeping it means flipping the prop back to 'mapbox' is a one-word change with nothing
|
||||
// else to restore. That is what "hot-swappable" has to mean to be worth anything.
|
||||
const mapProvider = computed(() => (cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox'))
|
||||
// ★ READ BOTH LEVELS, for the same reason `style` below does. This originally read ONLY
|
||||
// `cfg.provider`, while every other map setting (mapStyle, bins, geoFacet, colorRamp) lives under
|
||||
// `cfg.map`. A host that put `provider` where the rest of its map config already sits got NO
|
||||
// error and NO swap — the map silently stayed on Mapbox and the config looked correct. That is
|
||||
// the exact failure the note on `style` below names: a setting silently ignored because it sat
|
||||
// one level off is worse than no setting. `cfg.map.provider` is the spelling to prefer; the
|
||||
// top-level one is kept working so nothing already using it breaks.
|
||||
const mapProvider = computed(() => {
|
||||
const p = cfg.map?.provider || cfg.provider
|
||||
return p === 'maplibre' ? 'maplibre' : 'mapbox'
|
||||
})
|
||||
// Derived once so the source/layer branches in addLayers read the SAME decision the engine was
|
||||
// loaded with — a second inline `cfg.provider === …` test is how the two drift apart.
|
||||
const isMaplibreProvider = computed(() => mapProvider.value === 'maplibre')
|
||||
|
||||
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
|
||||
containerRef: mapContainer, accessToken: unref(mapboxToken) || (mapboxToken && mapboxToken.value) || '',
|
||||
center: [28, 18], zoom: 1.45, minZoom: 1,
|
||||
// ★ ZOOMED OUT BY DEFAULT, WITH REAL ZOOM-OUT RANGE (Driver 2026-09-23: "I don't want the map
|
||||
// zoomed in by default but zoomed out... the edges of the map need to be truly around the edges
|
||||
// so you have more zoom control and zoom function distance"). zoom 1.45 -> 1.1 opens on the
|
||||
// whole sphere; minZoom 1 -> 0.5 plus skipWorldFloor gives the zoom-out gesture somewhere to
|
||||
// go. The floor being skipped is the mercator "no duplicate world" rule, which is meaningless
|
||||
// on a globe -- a sphere HAS no second world to show. Flat mercator keeps the floor.
|
||||
center: [28, 18], zoom: 1.1, minZoom: 0.5, skipWorldFloor: true,
|
||||
provider: mapProvider.value,
|
||||
// A named style resolves per-provider via the registry; leaving it unset keeps each engine's own
|
||||
// default, which is what shipped before this change.
|
||||
|
|
@ -464,7 +490,14 @@ function setLiveResize(on) {
|
|||
const BIN_COLORS = (() => {
|
||||
const RAMP_PRESETS = {
|
||||
'heartbeat-red': ['#f4cec4', '#ecaea1', '#e08678', '#ce544b', '#b10010'],
|
||||
greyscale: ['#e8e8e8', '#a9a9a9', '#6f6f6f', '#3a3a3a', '#060606'],
|
||||
// ★ BIN 1 DARKENED #e8e8e8 -> #c9c9c9 (Driver 2026-09-23: "bangladesh is not showing up as a
|
||||
// polygon"). It WAS rendering -- queryRenderedFeatures returned the BGD polygon painted
|
||||
// #e8e8e8 -- but against the #f3f3f1 no-data surface that is a 1.06 contrast ratio at 0.78
|
||||
// opacity: a 1-4-group country was pixel-identical to an empty one. The ramp's first step
|
||||
// must be separated from NO-DATA at least as clearly as from bin 2, or the lowest bin
|
||||
// silently reads as "not in the data" (ColorBrewer sequential ramps assume no competing
|
||||
// no-data fill on the same canvas; when there is one, start the ramp a step darker).
|
||||
greyscale: ['#c9c9c9', '#a9a9a9', '#6f6f6f', '#3a3a3a', '#060606'],
|
||||
}
|
||||
return RAMP_PRESETS[cfg.map?.colorRamp] || RAMP_PRESETS['heartbeat-red']
|
||||
})()
|
||||
|
|
@ -749,9 +782,54 @@ function refreshSelected() {
|
|||
function addLayers() {
|
||||
const m = map.value; if (!m || added) return
|
||||
added = true
|
||||
if (!m.getSource('rd-countries')) m.addSource('rd-countries', { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
|
||||
// ── COUNTRY POLYGONS: the choropleth's source, PER PROVIDER ─────────────────────────────────
|
||||
// ★ THIS IS WHY THE MapLibre SWAP IS NOT ONE CONFIG LINE. The choropleth read from
|
||||
// `mapbox://mapbox.country-boundaries-v1`, a Mapbox-hosted VECTOR TILESET. MapLibre cannot
|
||||
// fetch a mapbox:// URL at all — it throws
|
||||
// Fetch API cannot load mapbox://mapbox.country-boundaries-v1. URL scheme "mapbox" is not supported
|
||||
// and then renders the basemap perfectly with NO choropleth on it. MEASURED, not predicted:
|
||||
// the first MapLibre screenshot showed a correct black-and-blue galaxy map on which every
|
||||
// landmass was uniform black while the legend still advertised five populated bins. It looked
|
||||
// like a deliberate dark style. The primary data layer was simply missing.
|
||||
//
|
||||
// So the swap needs a KEYLESS equivalent, and the tree already ships one:
|
||||
// assets/boundaries/world-adm0.geojson (162 ADM0 features), already staged by generate-tree
|
||||
// and already consumed by three other plugins (upg-100-list, connectivity-map,
|
||||
// resources-map) through the same resolveAssetsBase helper. No new asset, no new dependency.
|
||||
//
|
||||
// ★ THE KEY BRIDGE, AND WHY IT IS DONE HERE RATHER THAN IN THE EXPRESSIONS. The tileset names
|
||||
// the ISO field `iso_3166_1_alpha_3`; the geojson names it `iso3`. Six separate places read
|
||||
// that field — buildPaint's fill-color AND fill-opacity match expressions, the click handler,
|
||||
// the hover handler, and the queryRenderedFeatures path. Branching each of them would be six
|
||||
// chances to miss one, and a missed one fails silently (a `match` on an absent property just
|
||||
// falls through to its default, i.e. renders as no-data). Instead the property is normalized
|
||||
// ONCE, as the data is loaded, so every downstream expression keeps reading the single name it
|
||||
// always read and the Mapbox path stays byte-for-byte unchanged.
|
||||
if (!m.getSource('rd-countries')) {
|
||||
if (isMaplibreProvider.value) {
|
||||
// Add EMPTY first, then setData after the fetch: addLayer below must run synchronously to
|
||||
// keep this layer underneath the pins (layer order is insertion order). An awaited fetch
|
||||
// here would put the choropleth on TOP of every bubble.
|
||||
m.addSource('rd-countries', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0.geojson`)
|
||||
.then((r) => r.json())
|
||||
.then((gj) => {
|
||||
for (const f of gj.features || []) {
|
||||
if (f.properties && f.properties.iso3) f.properties.iso_3166_1_alpha_3 = f.properties.iso3
|
||||
}
|
||||
const src = map.value && map.value.getSource('rd-countries')
|
||||
if (src) src.setData(gj)
|
||||
})
|
||||
.catch((e) => console.warn('[rdash] country boundaries failed to load', e))
|
||||
} else {
|
||||
m.addSource('rd-countries', { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
|
||||
}
|
||||
}
|
||||
const { fillColor, fillOpacity } = buildPaint()
|
||||
m.addLayer({ id: 'rd-choropleth', type: 'fill', source: 'rd-countries', 'source-layer': 'country_boundaries',
|
||||
m.addLayer({ id: 'rd-choropleth', type: 'fill', source: 'rd-countries',
|
||||
// 'source-layer' applies to VECTOR TILE sources only. On a geojson source MapLibre rejects
|
||||
// the layer outright ("does not need a source-layer"), so it is set for Mapbox alone.
|
||||
...(isMaplibreProvider.value ? {} : { 'source-layer': 'country_boundaries' }),
|
||||
// COUNTRY BORDERS. The outline was '#ffffff', which was legible only because the fill
|
||||
// underneath ran to black. On a pale ramp a white border measures 1.11–1.84 — it would
|
||||
// vanish and the map would read as one undifferentiated wash. The Material Light MUTED
|
||||
|
|
@ -959,6 +1037,23 @@ function addLayers() {
|
|||
}
|
||||
} catch (_) { /* verify hook */ }
|
||||
applyProjection()
|
||||
// ★ THE GALAXY, THE WAY MAPBOX DOES IT (Driver: "find out how Mapbox does the galaxy stars and
|
||||
// see if we can use that"). Mapbox GL renders space and the starfield ENGINE-SIDE via the fog
|
||||
// API -- documented at docs.mapbox.com/mapbox-gl-js/guides/globe/ ("stars and atmosphere are
|
||||
// customized with the fog property"). These are their documented example values with one
|
||||
// change: space-color is pure black on the Driver's instruction ("not grey"). star-intensity
|
||||
// 0..1 is the engine's own star layer -- no canvas, no custom code, the actual Mapbox stars.
|
||||
// Guarded: MapLibre has no setFog (its equivalent is the sky spec), so on that provider this is
|
||||
// a silent no-op and the plain black container behind the transparent sphere carries the void.
|
||||
try {
|
||||
if (typeof m.setFog === 'function') m.setFog({
|
||||
'color': 'rgb(186, 210, 235)', // lower-atmosphere haze at the horizon
|
||||
'high-color': 'rgb(36, 92, 223)', // upper atmosphere
|
||||
'horizon-blend': 0.02,
|
||||
'space-color': '#000000', // the void — black, not grey
|
||||
'star-intensity': 0.6,
|
||||
})
|
||||
} catch (_) { /* an engine without fog keeps the container background instead */ }
|
||||
// selection pen: rebuild the highlight source whenever the selectionSet changes (toggle on/off).
|
||||
if (bus) bus.on('select', refreshSelected)
|
||||
refreshSelected()
|
||||
|
|
@ -1246,7 +1341,21 @@ function syncAttribClearance() {
|
|||
|
||||
// ── RDASH-ENHANCE: GLOBE (default) <-> MERCATOR projection toggle ──
|
||||
const projection = ref('globe')
|
||||
function applyProjection() { const m = map.value; if (m) { try { m.setProjection(projection.value) } catch (_) {} } }
|
||||
// ★ THE TWO ENGINES DISAGREE ON THIS ONE CALL, AND THE CATCH HID IT (Driver 2026-09-23: "the map
|
||||
// style is not a globe"). Mapbox GL takes setProjection('globe') -- a bare string. MapLibre v5 takes
|
||||
// setProjection({ type: 'globe' }) -- an object. Passing a string to MapLibre throws, the empty
|
||||
// catch below swallowed it, and the map silently fell back to flat mercator with NO console error.
|
||||
// That is why this appeared the moment the dashboard moved to provider:'maplibre': the default was
|
||||
// still 'globe' and the request simply stopped arriving.
|
||||
// Try the object form first (MapLibre, and harmless to attempt), then the string (Mapbox). The catch
|
||||
// stays because a provider without ANY projection support must degrade rather than break the panel --
|
||||
// but it now catches a genuine unsupported-engine case instead of masking a shape mismatch.
|
||||
function applyProjection() {
|
||||
const m = map.value
|
||||
if (!m || typeof m.setProjection !== 'function') return
|
||||
try { m.setProjection({ type: projection.value }); return } catch (_) { /* not the object form */ }
|
||||
try { m.setProjection(projection.value) } catch (_) { /* engine has no projection support */ }
|
||||
}
|
||||
function toggleProjection() { projection.value = projection.value === 'globe' ? 'mercator' : 'globe'; applyProjection() }
|
||||
|
||||
// ── RDASH-ENHANCE: fly-to on selection. Any facet brush (tree node / chart slice / region / row)
|
||||
|
|
@ -1428,8 +1537,18 @@ watch(() => JSON.stringify(filterSet), () => { clearTimeout(_flyT); _flyT = setT
|
|||
|
||||
useShadowStyles(`
|
||||
.rd-mapwrap{ position:relative; width:100%; height:100%; min-height:0; background:var(--dx-line, #d1d4cd); }
|
||||
/* ★ THE GALAXY IS THE CONTAINER, NOT THE TILES (Driver 2026-09-23, correcting the first attempt:
|
||||
"I asked for a spherical globe with a black background — the galaxy — with blue oceans like the
|
||||
Mapbox map, and the land was NOT to be black"). On a globe projection the sphere does not fill
|
||||
the canvas; the corners show the CONTAINER's background. So space lives here: a deep radial
|
||||
wash behind the sphere, while the tiles keep their normal daylight colours. Scoped to
|
||||
[data-projection="globe"] so flat mercator keeps the neutral ground — space behind a flat
|
||||
rectangle would read as a mistake. */
|
||||
/* Plain BLACK behind the sphere (Driver: "if not, then just do black background, not grey").
|
||||
On Mapbox the engine paints its own space + stars over the whole canvas via fog, so this only
|
||||
shows on engines without fog support (MapLibre), where black is the honest fallback. */
|
||||
.rd-mapwrap[data-projection="globe"]{ background:#000; }
|
||||
.rd-mapwrap .rd-mapc{ position:absolute; inset:0; }
|
||||
.rd-maptag{ position:absolute; left:8px; bottom:8px; z-index:2; font-size:10px; color:#33415588; background:#ffffffcc; padding:2px 6px; border-radius:4px; }
|
||||
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
|
||||
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
|
||||
.rd-proj:hover{ background:#1a1a2e; }
|
||||
|
|
@ -1464,7 +1583,8 @@ useShadowStyles(`
|
|||
The fix RAISES THE LEGEND, and never touches the attribution: nothing here hides, shrinks
|
||||
or restyles .mapboxgl-ctrl-attrib. --rd-attrib-clear is the reserved strip along the bottom
|
||||
edge that the credit line owns; the legend's bottom edge starts above it.
|
||||
Bottom-LEFT was not available — .rd-maptag already holds that corner.
|
||||
Bottom-LEFT was not available when this was written (a caption held that corner; it has since
|
||||
been removed, so the corner is free if a future control needs it).
|
||||
Stays on the right so the legend sits beside the choropleth it explains. */
|
||||
.rd-mapwrap{ --rd-attrib-clear:26px; }
|
||||
.rd-legend-map{ position:absolute; right:8px; bottom:calc(var(--rd-attrib-clear,26px) + 8px); z-index:2;
|
||||
|
|
@ -1525,7 +1645,6 @@ useShadowStyles(`
|
|||
<span class="rd-proj" role="button" tabindex="0" data-proj-toggle @click="toggleProjection">
|
||||
{{ projection === 'globe' ? '🌐 Globe' : '▭ Flat' }}
|
||||
</span>
|
||||
<div class="rd-maptag">UPG choropleth · click a country to filter · click a pin to select</div>
|
||||
<div class="rd-legend-map" data-map-legend aria-label="UPG count legend">
|
||||
<div class="lg-h">UPGs / country</div>
|
||||
<div v-for="b in LEGEND_BINS" :key="b.l" class="lg-row">
|
||||
|
|
|
|||
|
|
@ -165,12 +165,25 @@ const filters = computed(() => dropdownKeys.value.map((key) => ({
|
|||
// ('Needs Prayer'), so passing it through a code resolver can only fail to find it and fall back —
|
||||
// at best a no-op, at worst a blank chip. The value-is-label axes need no resolution by definition.
|
||||
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
|
||||
const activeChips = computed(() =>
|
||||
Object.keys(filterSet).map((k) => {
|
||||
const activeChips = computed(() => {
|
||||
const out = Object.keys(filterSet).map((k) => {
|
||||
const v = filterSet[k]
|
||||
return { name: k, label: `${FACETS.value[k]?.label || k}: ${k === PRAYER_KEY ? v : labelFor(k, v)}` }
|
||||
})
|
||||
)
|
||||
// ★ THE TYPED SEARCH IS A FILTER AND MUST LOOK LIKE ONE (Driver 2026-09-23: "I searched Bhutan
|
||||
// and it did not switch the toggle to show the country was selected"). MEASURED: typing narrows
|
||||
// the scope live via bus.searchTerm (2,107 -> 13) while filterSet stays empty, so the data
|
||||
// changed and no control anywhere said why -- invisible state, the same class as the silent
|
||||
// grey pane. The conventional faceted-search answer is that free text is its OWN chip beside
|
||||
// the facet chips, clearable like any of them. It deliberately does NOT masquerade as a Country
|
||||
// selection: the term matches across ALL searchKeys (name, region, language, religion...), so
|
||||
// labelling it a country filter would lie whenever it matches something else.
|
||||
// (First attempt went into DashboardFilterChips.vue -- a component the topbar dedup pass had
|
||||
// already unmounted; THIS band is the documented single source of truth for chips.)
|
||||
const term = (bus?.searchTerm?.value || '').trim()
|
||||
if (term) out.push({ name: '__search__', label: `Search: ${term}` })
|
||||
return out
|
||||
})
|
||||
|
||||
const searchValue = computed(() => bus?.searchTerm?.value || '')
|
||||
|
||||
|
|
@ -178,8 +191,11 @@ const searchValue = computed(() => bus?.searchTerm?.value || '')
|
|||
function onSearch(payload) { bus?.setSearch(payload?.value ?? '') }
|
||||
function onFilterChange(detail) { bus?.setFilter(detail.name, detail.value) }
|
||||
function onFilterClear(detail) { bus?.setFilter(detail.name, null) }
|
||||
function onRemoveFilter(detail) { bus?.setFilter(detail.name, null) }
|
||||
function onClearAll() { bus?.clearFilters() }
|
||||
function onRemoveFilter(detail) {
|
||||
if (detail.name === '__search__') { bus?.setSearch(''); return }
|
||||
bus?.setFilter(detail.name, null)
|
||||
}
|
||||
function onClearAll() { bus?.clearFilters(); bus?.setSearch('') }
|
||||
|
||||
useShadowStyles(`
|
||||
/* header-2 host: the research-filters band sits in the dashboard's dedicated 'filters' grid row.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* A dashboard shell (see plugin-designs/doxa-research-dashboards/research-dashboards.vue) is a CSS
|
||||
* grid whose three flexible tracks are driven by CSS custom properties:
|
||||
* --facet-w (left facet-rail column width) default 296px
|
||||
* --rail-w (right charts-rail column width) default 264px
|
||||
* --rail-w (right charts-rail column width) default 296px — equals --facet-w, so the map column sits centred
|
||||
* --table-h (bottom table row height) default 34% (map above it is the 1fr hero)
|
||||
*
|
||||
* This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge),
|
||||
|
|
@ -85,7 +85,36 @@ import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
|
|||
// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table
|
||||
// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split;
|
||||
// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above).
|
||||
const DEFAULTS = { facetW: 296, railW: 264, tableH: '34%', mapH: 360, shellH: 720 }
|
||||
// ★ shellH 720 -> 602 so the table OPENS at seven data rows (Driver 2026-09-23: "the table needs to
|
||||
// only show SEVEN rows by DEFAULT height"). 602 = map 360 + bar 32 + 7 rows x 26 + foot 28.
|
||||
// ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped
|
||||
// the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes
|
||||
// --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not
|
||||
// be reached by any gesture. The Driver lost BOTH resizes at once and said so: "I didn't want the
|
||||
// table set to 7. Only the table needs to be resizable. We've set it to where I can't even resize."
|
||||
// Setting the STARTING VALUE instead leaves the derivation intact: the keyhole stays
|
||||
// shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is
|
||||
// merely where it opens.
|
||||
// ★ RECLAIM PASS 2 (Driver 2026-09-23: "it is a priority to reclaim height... so I can see half
|
||||
// of the first row of the people groups grid"). mapH 360 -> 300 and shellH follows the same
|
||||
// arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table
|
||||
// promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING
|
||||
// MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a
|
||||
// shorter map shows MORE world, not less.
|
||||
// shellH 502 -> 542 (Driver 2026-09-23: "the dashboard should be taller by about 40px"). The
|
||||
// extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot);
|
||||
// the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in
|
||||
// research-dashboard.vue kept byte-equal at 542.
|
||||
// mapH 260 -> 300 (Driver 2026-09-23 follow-up: the +40px was to be "added to the map -- map is
|
||||
// 40px taller display room"). shellH stays 542, so the table's derived share returns to its
|
||||
// pre-+40 height and the whole gain lands in the map row.
|
||||
// +40px again (Driver 2026-09-23 second raise: "go taller with the dashboard by another 40px,
|
||||
// add that to the height of the map"): shellH 542 -> 582 and mapH 300 -> 340 move together, so
|
||||
// the whole gain lands in the map row and the table's derived share stays put.
|
||||
// Driver fine-tune 2026-09-23: "almost perfect height -- keep the map the same height, but
|
||||
// decrease the height of the dashboard by about 20px". Map stays 340; the 20px comes out of
|
||||
// the table's derived share (shell - map - bar - foot).
|
||||
const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 340, shellH: 562 }
|
||||
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] }
|
||||
|
||||
// Mirrors the CSS fallback --rd-table-bar-h (research-dashboard.vue's .rd-table-bar budget calc) —
|
||||
|
|
@ -107,7 +136,15 @@ export function useDashboardLayout(options = {}) {
|
|||
bus = null,
|
||||
isNarrow = false,
|
||||
pageScroll = false,
|
||||
storageKey = 'rd-dashboard-layout',
|
||||
// ★ KEY VERSIONED v2 (2026-09-23). The Driver reported "sidebars are not equally spaced" on
|
||||
// his machine AFTER the symmetric-rail change landed -- because his browser restores a layout
|
||||
// SAVED under the old defaults (rail 264), and a restored value outranks any new default by
|
||||
// design. When defaults change materially, the old saved layouts are prescriptions from a
|
||||
// different bottle: bump the key so every reader starts from the new defaults once, and a
|
||||
// fresh drag re-personalises from there. The v1 blob is left in place, inert.
|
||||
// v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted
|
||||
// shellH:502 from the previous default would silently mask this change on the Driver's browser.
|
||||
storageKey = 'rd-dashboard-layout-v6',
|
||||
} = options
|
||||
|
||||
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
* responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync.
|
||||
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
|
||||
*/
|
||||
import { exactMatches, fuzzyMatches } from '@map/components/search/fuzzy.js'
|
||||
import { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { createSelectionBus } from './useSelectionBus.js'
|
||||
import { useDashboardData } from './useDashboardData.js'
|
||||
|
|
@ -59,15 +60,36 @@ export function buildSearchAccessors(config) {
|
|||
* 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()
|
||||
// ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must
|
||||
// still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with
|
||||
// { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
|
||||
// search tolerates typos. This matcher was a plain .includes() -- the one search surface that
|
||||
// silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
|
||||
// It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the
|
||||
// grid's Fuse uses -- with the same DX-life options, so every search surface agrees.
|
||||
// Row-level predicates for the TIERED contract (see fuzzy.js exactMatches): the caller
|
||||
// (filterRowsBySearch below) runs the exact tier over the whole row set first and falls back
|
||||
// to the fuzzy tier only when exact yields nothing, so an exact term agrees with the facet.
|
||||
const test = (row, pred) => {
|
||||
const needle = (bus?.searchTerm?.value || '').trim()
|
||||
if (!needle) return true
|
||||
for (const acc of accessors) {
|
||||
const v = acc(row)
|
||||
if (v != null && String(v).toLowerCase().includes(needle)) return true
|
||||
if (v != null && pred(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const matcher = (row) => test(row, fuzzyMatches)
|
||||
matcher.exact = (row) => test(row, exactMatches)
|
||||
matcher.active = () => !!(bus?.searchTerm?.value || '').trim()
|
||||
return matcher
|
||||
}
|
||||
|
||||
/** Apply the tiered search to a row set: exact tier first, fuzzy only as the typo rescue. */
|
||||
export function filterRowsBySearch(rows, matcher) {
|
||||
if (!matcher.active()) return rows
|
||||
const exact = rows.filter(matcher.exact)
|
||||
return exact.length ? exact : rows.filter(matcher)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -153,20 +175,61 @@ function useResponsiveShell(bus) {
|
|||
|
||||
/**
|
||||
* Shareable view-state hash URL: serialize filterSet to location.hash; restore on mount.
|
||||
* Extracted VERBATIM from research-dashboard.vue.
|
||||
*
|
||||
* ★ READABLE, CODE-CANONICAL FORMAT (card BUG-SHARE-URL-LEAKS-RAW-CODES). The old hash was
|
||||
* URL-encoded JSON -- #f=%7B%22affinity_bloc%22%3A%22A008%22%7D -- unreadable twice over: JSON
|
||||
* punctuation percent-escaped, and the value a bare API code. A URL is the one part of the
|
||||
* product a user COPIES, PASTES and READS, and for sensitive population data a link you cannot
|
||||
* sanity-check before clicking is a real cost. The format is now
|
||||
*
|
||||
* #f=affinity_bloc:A008*malay-peoples;religion_family:Hinduism
|
||||
*
|
||||
* -- the Stack Overflow /questions/123/slug pattern: the CODE is canonical and the *slug is
|
||||
* DISPLAY-ONLY, appended from labelFor() at share time and STRIPPED on parse. Restoring never
|
||||
* reads the slug, so links stay locale-independent and survive label renames (the card's
|
||||
* explicit constraint: keep the code canonical, do not swap in localized labels).
|
||||
*
|
||||
* `*` is safe as the slug separator because escapeValue() percent-encodes any literal `*` in
|
||||
* the VALUE first -- the separator cannot collide with content. `;` and `:` are legal fragment
|
||||
* chars, so the whole thing stays legible in the address bar. Legacy JSON hashes (old shared
|
||||
* links) are detected by their leading `{` / %7B and still restore.
|
||||
*/
|
||||
function useHashSync(bus) {
|
||||
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
||||
// Minimal escaping, maximal legibility: reserve our three delimiters + %, keep spaces as `+`
|
||||
// (the x-www-form-urlencoded convention), leave everything else -- including unicode -- alone.
|
||||
const escapeValue = (s) => String(s).replace(/[%;:*+]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
|
||||
const unescapeValue = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, '%20')) } catch (_) { return String(s).replace(/\+/g, ' ') } }
|
||||
|
||||
function useHashSync(bus, labelFor) {
|
||||
function syncHash() {
|
||||
try {
|
||||
const enc = encodeURIComponent(JSON.stringify(bus.filterSet))
|
||||
if (typeof location !== 'undefined') history.replaceState(null, '', '#f=' + enc)
|
||||
const pairs = Object.entries(bus.filterSet).map(([k, v]) => {
|
||||
const label = typeof labelFor === 'function' ? labelFor(k, v) : null
|
||||
const slug = label && String(label) !== String(v) ? slugify(label) : ''
|
||||
return k + ':' + escapeValue(v) + (slug ? '*' + slug : '')
|
||||
})
|
||||
if (typeof location !== 'undefined') history.replaceState(null, '', '#f=' + pairs.join(';'))
|
||||
} catch (_) { /* sandboxed */ }
|
||||
}
|
||||
watch(() => ({ ...bus.filterSet }), syncHash, { deep: true })
|
||||
onMounted(() => {
|
||||
try {
|
||||
const m = typeof location !== 'undefined' && /[#&]f=([^&]+)/.exec(location.hash)
|
||||
if (m) { const obj = JSON.parse(decodeURIComponent(m[1])); Object.entries(obj).forEach(([k, v]) => bus.setFilter(k, v)) }
|
||||
if (!m) return
|
||||
const raw = m[1]
|
||||
if (raw.startsWith('{') || raw.startsWith('%7B') || raw.startsWith('%7b')) {
|
||||
// legacy JSON hash from an old shared link
|
||||
const obj = JSON.parse(decodeURIComponent(raw))
|
||||
Object.entries(obj).forEach(([k, v]) => bus.setFilter(k, v))
|
||||
return
|
||||
}
|
||||
for (const pair of raw.split(';')) {
|
||||
const i = pair.indexOf(':')
|
||||
if (i < 1) continue
|
||||
const key = pair.slice(0, i)
|
||||
const value = unescapeValue(pair.slice(i + 1).split('*')[0])
|
||||
if (key && value) bus.setFilter(key, value)
|
||||
}
|
||||
} catch (_) { /* ignore bad hash */ }
|
||||
})
|
||||
}
|
||||
|
|
@ -199,9 +262,11 @@ export function useDashboardShell(config) {
|
|||
const keys = Object.keys(bus.filterSet)
|
||||
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)
|
||||
)
|
||||
const faceted = keys.length
|
||||
? rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }))
|
||||
: rows
|
||||
// Tiered search over the faceted scope: exact first, fuzzy only if exact finds nothing.
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
})
|
||||
|
||||
// linked KPI strip — recomputes on every brush.
|
||||
|
|
@ -210,9 +275,6 @@ export function useDashboardShell(config) {
|
|||
// responsive flags + collapsible table dock + segmented mobile view.
|
||||
const responsive = useResponsiveShell(bus)
|
||||
|
||||
// shareable view-state hash URL.
|
||||
useHashSync(bus)
|
||||
|
||||
// ── dataStore adapter (GAP B) — DataSourceManager-normalized (DASH-FIX-2) ──
|
||||
// The header's GeocoderComponent bars (DOXA-LIB-3) run useEntitySearch over an
|
||||
// injected `dataStore` to surface local people-group / country / language /
|
||||
|
|
@ -259,6 +321,10 @@ export function useDashboardShell(config) {
|
|||
// here, below `dataStore`, because the resolver reads the normalized features it exposes.
|
||||
// Data columns are unaffected (see useCsvExport.buildCsv).
|
||||
const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId)
|
||||
|
||||
// shareable view-state hash URL -- below labelFor on purpose: the hash's display-only slugs
|
||||
// resolve through the SAME code->name resolver the chips and the CSV provenance line use.
|
||||
useHashSync(bus, labelFor)
|
||||
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor })
|
||||
|
||||
provide('selectionBus', bus)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,18 @@ const MAP_H = '88svh'
|
|||
// SILENTLY EMPTY section: StackLayout's .catch() deliberately leaves a failed section's host empty
|
||||
// so the rest of the stack still renders, which means a wrong path costs you a band with NO error.
|
||||
// `bun run check:composite-srcs` resolves every composite src against the built tree to catch it.
|
||||
// ★ A DEV ENTRY AND A BUILT BUNDLE DO NOT LIVE AT THE SAME URL, so one literal cannot name both.
|
||||
// In dev, vite serves each bundle's SOURCE entry at /plugin-designs/<bundle>/index.js; there is no
|
||||
// <bundle>/<bundle>.js to reach, so '../../doxa-research-map/doxa-research-map.js' 404s and the
|
||||
// section renders EMPTY (StackLayout's .catch() keeps the rest of the stack alive, which is exactly
|
||||
// why an empty band is easy to miss). Same helper, same reasoning, as stack-demo.vue — which hit
|
||||
// this first and wrote down that a literal '../..' "looks correct in review and is invisible to a
|
||||
// gate that only ever checks the built tree", which is why check:composite-srcs passes on it.
|
||||
// import.meta.env.DEV is statically replaced at build time, so the production branch is the ONLY
|
||||
// one that survives into the IIFE: the built output is byte-identical to the literal it replaces.
|
||||
const bundleSrc = (bundle, file) =>
|
||||
import.meta.env.DEV ? `/plugin-designs/${bundle}/index.js` : `../../${bundle}/${file}.js`
|
||||
|
||||
const sections = [
|
||||
// No section title: the research map self-titles through its own chrome, so a StackLayout title
|
||||
// here would render the name twice — the duplicate-header bug the Driver rejected on the desktop
|
||||
|
|
@ -98,7 +110,7 @@ const sections = [
|
|||
{
|
||||
key: 'map',
|
||||
element: 'doxa-research-map',
|
||||
src: '../../doxa-research-map/doxa-research-map.js',
|
||||
src: bundleSrc('doxa-research-map', 'doxa-research-map'),
|
||||
// ★ profile-config IS MANDATORY FOR THIS CHILD, and omitting it was a real shipped defect
|
||||
// (found + fixed 2026-09-22 by READING the regenerated README screenshot, not by any gate).
|
||||
// doxa-research-map does not mount a component directly — its bundle entry mounts ProfileLoader,
|
||||
|
|
@ -154,7 +166,7 @@ const sections = [
|
|||
{
|
||||
key: 'grid',
|
||||
element: 'people-groups-grid',
|
||||
src: '../../people-groups-grid/people-groups-grid.js',
|
||||
src: bundleSrc('people-groups-grid', 'people-groups-grid'),
|
||||
attrs: { 'show-search': 'true', 'show-filters': 'true', 'show-total': 'true' }
|
||||
}
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
|
||||
<script src="../all-people-groups.js" integrity="sha384-ajyiOLGnsr8AOhrZpwopjFXwKGNk56IbWkBMD+2oYA+DSDjdtATeoF5muZoXhcEF" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-xK62b4nEd/ei3xDdRNOOCl9YyDRliPUvS8dtna07xFFR1nwPqaCiAHBNuxRtuaIH" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
14
public/doxa-maps/big-picture/big-picture.js
generated
14
public/doxa-maps/big-picture/big-picture.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../../big-picture.js" integrity="sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../../big-picture.js" integrity="sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../big-picture.js" integrity="sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43" crossorigin="anonymous"></script>
|
||||
<script src="../big-picture.js" integrity="sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
59
public/doxa-maps/country-posters/country-posters.js
generated
59
public/doxa-maps/country-posters/country-posters.js
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0" crossorigin="anonymous"></script>
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0" crossorigin="anonymous"></script>
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
24
public/doxa-maps/demo-map/demo-map.js
generated
24
public/doxa-maps/demo-map/demo-map.js
generated
File diff suppressed because one or more lines are too long
2
public/doxa-maps/demo-map/demo-map/index.html
generated
2
public/doxa-maps/demo-map/demo-map/index.html
generated
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
|
||||
<script src="../demo-map.js" integrity="sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO" crossorigin="anonymous"></script>
|
||||
<script src="../demo-map.js" integrity="sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
|
||||
<script src="../../demo-map.js" integrity="sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO" crossorigin="anonymous"></script>
|
||||
<script src="../../demo-map.js" integrity="sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
26
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
26
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
105
public/doxa-maps/doxa-research-map/doxa-research-map.js
generated
105
public/doxa-maps/doxa-research-map/doxa-research-map.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
|
||||
<script src="../doxa-research-map.js" integrity="sha384-ugm8PXu3KYQmGJoQnOUaICqt07r17kQa1bINkKsrUgt89uENqvd87rxT2NBeyC9/" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-research-map.js" integrity="sha384-rX8Z6H4/pnGt+1GrwGQRCooJ21TL4dQE7JZBD19mV0ow7bdagmo9TS0qbVtYrbx5" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
45
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
45
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
74
public/doxa-maps/manifest.json
generated
74
public/doxa-maps/manifest.json
generated
|
|
@ -9,7 +9,7 @@
|
|||
"bundle": "all-people-groups",
|
||||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"output": "doxa-maps/all-people-groups/all-people-groups.js",
|
||||
"integrity": "sha384-ajyiOLGnsr8AOhrZpwopjFXwKGNk56IbWkBMD+2oYA+DSDjdtATeoF5muZoXhcEF",
|
||||
"integrity": "sha384-xK62b4nEd/ei3xDdRNOOCl9YyDRliPUvS8dtna07xFFR1nwPqaCiAHBNuxRtuaIH",
|
||||
"elements": [
|
||||
"all-people-groups"
|
||||
],
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-ajyiOLGnsr8AOhrZpwopjFXwKGNk56IbWkBMD+2oYA+DSDjdtATeoF5muZoXhcEF\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-xK62b4nEd/ei3xDdRNOOCl9YyDRliPUvS8dtna07xFFR1nwPqaCiAHBNuxRtuaIH\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
},
|
||||
"bundle": "all-people-groups"
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
"bundle": "big-picture",
|
||||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"output": "doxa-maps/big-picture/big-picture.js",
|
||||
"integrity": "sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43",
|
||||
"integrity": "sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG",
|
||||
"elements": [
|
||||
"bp-big-picture",
|
||||
"bp-research-header",
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-a9W2VPCO3zJ+1pqWQadGso72rNPTfmZgO4aoGtZN7RDFQpVQbeTvy8JBsGi9VH43\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
"bundle": "country-posters",
|
||||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"output": "doxa-maps/country-posters/country-posters.js",
|
||||
"integrity": "sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO",
|
||||
"integrity": "sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55",
|
||||
"elements": [
|
||||
"country-posters-map"
|
||||
],
|
||||
|
|
@ -170,7 +170,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -184,7 +184,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-11tC7IzM7DM66gTcAlLSb0bwA7TTkfKYmdrYdiGlBTy9eLVhYCjKgD+LmhZ+qLwO\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -193,7 +193,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0",
|
||||
"integrity": "sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -213,7 +213,7 @@
|
|||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-w8aVTRyH8dlsHIjJMHrFe6QbnZaafEb3wGjykrVlqWvj+I0S5eMwXfyBHy/SS/X0\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -237,7 +237,7 @@
|
|||
"bundle": "demo-map",
|
||||
"scriptUrl": "./demo-map/demo-map.js",
|
||||
"output": "doxa-maps/demo-map/demo-map.js",
|
||||
"integrity": "sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO",
|
||||
"integrity": "sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D",
|
||||
"elements": [
|
||||
"demo-map"
|
||||
],
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
"scriptUrl": "./demo-map/demo-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./demo-map/demo-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-yLc6o2sikAUcTgqEszWaO9whzFZnBAZoO4gPXJhG3tzvfKaDpK2mhAW3qXwrycDO\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-30eVMRgp3BZf/wqP7A2s3/n3G62zmxdAyRX1m7dktVcAXDAva/pAdALScLNtY/2D\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -280,7 +280,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c",
|
||||
"integrity": "sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -304,7 +304,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-GNqHiINJmPj5jwlIbFd3WQ2FfQy9VwJ6gZEjisE/TmS23X7RjCEz5YL81gKSVD5c\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-Ce+DKsLgwfej+rgoWeaWFuMMbR3Y4H+8io/lVL+lQfX0O3iGKwDLf4remDkHGCrP\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -331,7 +331,7 @@
|
|||
"bundle": "doxa-research-map",
|
||||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
|
||||
"integrity": "sha384-ugm8PXu3KYQmGJoQnOUaICqt07r17kQa1bINkKsrUgt89uENqvd87rxT2NBeyC9/",
|
||||
"integrity": "sha384-rX8Z6H4/pnGt+1GrwGQRCooJ21TL4dQE7JZBD19mV0ow7bdagmo9TS0qbVtYrbx5",
|
||||
"elements": [
|
||||
"doxa-research-map"
|
||||
],
|
||||
|
|
@ -347,7 +347,7 @@
|
|||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-ugm8PXu3KYQmGJoQnOUaICqt07r17kQa1bINkKsrUgt89uENqvd87rxT2NBeyC9/\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-rX8Z6H4/pnGt+1GrwGQRCooJ21TL4dQE7JZBD19mV0ow7bdagmo9TS0qbVtYrbx5\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -356,7 +356,7 @@
|
|||
"bundle": "doxa-simple-map",
|
||||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"output": "doxa-maps/doxa-simple-map/doxa-simple-map.js",
|
||||
"integrity": "sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu",
|
||||
"integrity": "sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+",
|
||||
"elements": [
|
||||
"doxa-map",
|
||||
"doxa-simple-map"
|
||||
|
|
@ -373,7 +373,7 @@
|
|||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-fl3Eh//QzBhOEpF3OJcfZDXOdpyb/yxiQ5aEroT23Onjya6wvBlx+F2dUB6FiBZu\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -623,7 +623,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE",
|
||||
"integrity": "sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -643,7 +643,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -657,7 +657,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -671,7 +671,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -680,7 +680,7 @@
|
|||
"bundle": "people-groups-grid",
|
||||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"output": "doxa-maps/people-groups-grid/people-groups-grid.js",
|
||||
"integrity": "sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg",
|
||||
"integrity": "sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG",
|
||||
"elements": [
|
||||
"people-groups-grid"
|
||||
],
|
||||
|
|
@ -696,7 +696,7 @@
|
|||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -741,7 +741,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "doxa-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp",
|
||||
"integrity": "sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -769,7 +769,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -796,7 +796,7 @@
|
|||
"bundle": "prayer-walk-planner",
|
||||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
|
||||
"integrity": "sha384-+XBRYgeeX8JLpdYcc+oGtkQf+Zgc2JkFK6/tlMBh5qghF9rNAJLk1lwVxWkU0cKx",
|
||||
"integrity": "sha384-/lrJX10JhiaXgpDAv0tf4R7ycgOaBRG+8oDasqHnD9MomT8ZGiykoh5+/vCJSJsr",
|
||||
"elements": [
|
||||
"prayer-walk-planner"
|
||||
],
|
||||
|
|
@ -812,7 +812,7 @@
|
|||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-+XBRYgeeX8JLpdYcc+oGtkQf+Zgc2JkFK6/tlMBh5qghF9rNAJLk1lwVxWkU0cKx\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-/lrJX10JhiaXgpDAv0tf4R7ycgOaBRG+8oDasqHnD9MomT8ZGiykoh5+/vCJSJsr\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -821,7 +821,7 @@
|
|||
"bundle": "regions-dashboard",
|
||||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
|
||||
"integrity": "sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko",
|
||||
"integrity": "sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+",
|
||||
"elements": [
|
||||
"regions-dashboard"
|
||||
],
|
||||
|
|
@ -841,7 +841,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -855,7 +855,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -869,7 +869,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -878,7 +878,7 @@
|
|||
"bundle": "research-dashboard",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"output": "doxa-maps/research-dashboard/research-dashboard.js",
|
||||
"integrity": "sha384-sQL0nI6tuJD+PpCrg4TbNH5QwazCgMmk/oLwCH7QVbtoh4uYdoPhIPdokF1WLTgT",
|
||||
"integrity": "sha384-9/hF/6WuV7HVDQuZS1MbAOJCtvh+RsNVEcRoPBTbfTn8SUNYnW2A46OY8EXcznsO",
|
||||
"elements": [
|
||||
"research-dashboard-map"
|
||||
],
|
||||
|
|
@ -894,7 +894,7 @@
|
|||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-sQL0nI6tuJD+PpCrg4TbNH5QwazCgMmk/oLwCH7QVbtoh4uYdoPhIPdokF1WLTgT\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-9/hF/6WuV7HVDQuZS1MbAOJCtvh+RsNVEcRoPBTbfTn8SUNYnW2A46OY8EXcznsO\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
},
|
||||
"bundle": "research-dashboard"
|
||||
}
|
||||
|
|
@ -904,7 +904,7 @@
|
|||
"bundle": "research-map-plus-grid",
|
||||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"output": "doxa-maps/research-map-plus-grid/research-map-plus-grid.js",
|
||||
"integrity": "sha384-RD6XMWZvRaam/1XF9HAsXE+XutNjsboygfmxLMP9JJEnZ0LdIHqR+gYGWE1Y6Gzq",
|
||||
"integrity": "sha384-K7QdQDF5WffVlrlBuvJDUzH7wXLdXUhAQMjgB+svavqQimYzD9O4Jq/zanhiFjhG",
|
||||
"elements": [
|
||||
"research-map-plus-grid"
|
||||
],
|
||||
|
|
@ -924,7 +924,7 @@
|
|||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-map-plus-grid/research-map-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-RD6XMWZvRaam/1XF9HAsXE+XutNjsboygfmxLMP9JJEnZ0LdIHqR+gYGWE1Y6Gzq\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-K7QdQDF5WffVlrlBuvJDUzH7wXLdXUhAQMjgB+svavqQimYzD9O4Jq/zanhiFjhG\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -948,7 +948,7 @@
|
|||
"bundle": "stack-demo",
|
||||
"scriptUrl": "./stack-demo/stack-demo.js",
|
||||
"output": "doxa-maps/stack-demo/stack-demo.js",
|
||||
"integrity": "sha384-g9iK/zw5CvcyhNWdWHe8n2S/O22tv3PhQhoCe45ArdX5dL2YRBBRl/hFjktel1UU",
|
||||
"integrity": "sha384-gnwM6s+puf1K60Nl7crQcC2QC+9BxwpkaTx4OepLZ8VUCraZc+aRlWpsb6nj6Gyf",
|
||||
"elements": [
|
||||
"stack-demo"
|
||||
],
|
||||
|
|
@ -968,7 +968,7 @@
|
|||
"scriptUrl": "./stack-demo/stack-demo.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./stack-demo/stack-demo/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./stack-demo/stack-demo.js\" integrity=\"sha384-g9iK/zw5CvcyhNWdWHe8n2S/O22tv3PhQhoCe45ArdX5dL2YRBBRl/hFjktel1UU\" crossorigin=\"anonymous\"></script>\n<stack-demo profile-config='{\"profile\":\"stack-demo\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></stack-demo>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./stack-demo/stack-demo.js\" integrity=\"sha384-gnwM6s+puf1K60Nl7crQcC2QC+9BxwpkaTx4OepLZ8VUCraZc+aRlWpsb6nj6Gyf\" crossorigin=\"anonymous\"></script>\n<stack-demo profile-config='{\"profile\":\"stack-demo\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></stack-demo>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
38
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
38
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-HnFFKkrNrn1G3n0Ytk55taH2k/0xGwgWh2vQwJZZWN6F2TTOD2X7vnMq8eqSXpnE" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg" crossorigin="anonymous"></script>
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
|
||||
<script src="../people-groups-grid.js" integrity="sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg" crossorigin="anonymous"></script>
|
||||
<script src="../people-groups-grid.js" integrity="sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-M3RVF4r+s9KJ2YznjQ+g3BR4XjsA4n8uhfCk97dwyiuN1BxRdeQmmMnQILV0CwJg" crossorigin="anonymous"></script>
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
51
public/doxa-maps/plugin-studio/plugin-studio.js
generated
51
public/doxa-maps/plugin-studio/plugin-studio.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../plugin-studio.js" integrity="sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp" crossorigin="anonymous"></script>
|
||||
<script src="../plugin-studio.js" integrity="sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../../plugin-studio.js" integrity="sha384-VuTJ3Db517xAN7nyV6VKdM0YJH6ToDwxyCjjAEeK69x8KompgYFCHWs29Q0BkfJp" crossorigin="anonymous"></script>
|
||||
<script src="../../plugin-studio.js" integrity="sha384-2RlLFRU2XdjBN7w+FPguj8INgj57JFg4IFT2AmdefGnR9QQLehbiiAffV45LcsFH" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-+XBRYgeeX8JLpdYcc+oGtkQf+Zgc2JkFK6/tlMBh5qghF9rNAJLk1lwVxWkU0cKx" crossorigin="anonymous"></script>
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-/lrJX10JhiaXgpDAv0tf4R7ycgOaBRG+8oDasqHnD9MomT8ZGiykoh5+/vCJSJsr" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
|
||||
<script src="../research-dashboard.js" integrity="sha384-sQL0nI6tuJD+PpCrg4TbNH5QwazCgMmk/oLwCH7QVbtoh4uYdoPhIPdokF1WLTgT" crossorigin="anonymous"></script>
|
||||
<script src="../research-dashboard.js" integrity="sha384-9/hF/6WuV7HVDQuZS1MbAOJCtvh+RsNVEcRoPBTbfTn8SUNYnW2A46OY8EXcznsO" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <research-map-plus-grid> custom element. -->
|
||||
<script src="../../research-map-plus-grid.js" integrity="sha384-RD6XMWZvRaam/1XF9HAsXE+XutNjsboygfmxLMP9JJEnZ0LdIHqR+gYGWE1Y6Gzq" crossorigin="anonymous"></script>
|
||||
<script src="../../research-map-plus-grid.js" integrity="sha384-K7QdQDF5WffVlrlBuvJDUzH7wXLdXUhAQMjgB+svavqQimYzD9O4Jq/zanhiFjhG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <research-map-plus-grid> custom element. -->
|
||||
<script src="../research-map-plus-grid.js" integrity="sha384-RD6XMWZvRaam/1XF9HAsXE+XutNjsboygfmxLMP9JJEnZ0LdIHqR+gYGWE1Y6Gzq" crossorigin="anonymous"></script>
|
||||
<script src="../research-map-plus-grid.js" integrity="sha384-K7QdQDF5WffVlrlBuvJDUzH7wXLdXUhAQMjgB+svavqQimYzD9O4Jq/zanhiFjhG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
16
public/doxa-maps/stack-demo/stack-demo.js
generated
16
public/doxa-maps/stack-demo/stack-demo.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <stack-demo> custom element. -->
|
||||
<script src="../stack-demo.js" integrity="sha384-g9iK/zw5CvcyhNWdWHe8n2S/O22tv3PhQhoCe45ArdX5dL2YRBBRl/hFjktel1UU" crossorigin="anonymous"></script>
|
||||
<script src="../stack-demo.js" integrity="sha384-gnwM6s+puf1K60Nl7crQcC2QC+9BxwpkaTx4OepLZ8VUCraZc+aRlWpsb6nj6Gyf" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <stack-demo> custom element. -->
|
||||
<script src="../../stack-demo.js" integrity="sha384-g9iK/zw5CvcyhNWdWHe8n2S/O22tv3PhQhoCe45ArdX5dL2YRBBRl/hFjktel1UU" crossorigin="anonymous"></script>
|
||||
<script src="../../stack-demo.js" integrity="sha384-gnwM6s+puf1K60Nl7crQcC2QC+9BxwpkaTx4OepLZ8VUCraZc+aRlWpsb6nj6Gyf" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue