Make dashboard selections actually filter the grid, and fit the camera to the data
Some checks are pending
build-gate / build (push) Waiting to run

Four fixes to the research dashboard and the cap-4 composite, all Driver-reported.

GRID SHOWED ZERO ON EVERY SELECTION. Picking a region showed "1,309" in the chart
above an empty grid below. The two halves spell the same value differently: the
dashboard emits the human LABEL ("Asia", "North America & non-Spanish Caribbean")
while the API stores the machine VALUE ("asia", "north_america_&_non-spanish_caribbean"),
and the comparison was a strict ===, so it never matched. Measured over the 2,107
live records. Adds a shared valueMatches() normaliser used by BOTH applyDropdownFilters
and applyWith, so facet counts and shown rows cannot disagree. Compares value OR label,
case- and separator-insensitively, chosen over a label-to-value lookup table that would
need maintaining per axis and would rot when the API adds a region.

The grid's own dropdowns were never affected, because they build their options from the
data itself; only the host-prop path could inject a label into the comparison. That is
why the standalone grid is fine and the gate passes 13/13 on the same records.

COUNTRY NOW REACHES THE GRID. Clicking India in Top Countries did nothing because
country_code had no matching prop and was deliberately dropped rather than guessed at.
Adds a real countryCode prop and maps it. Country is prop-only, not a fifth dropdown.

LONG FACET LABELS HID THE COUNTS. white-space:nowrap let a long label eat the row, and
the rail scrolls vertically only, so there was no way to recover the number. Labels now
wrap; the bar yields; the count never shrinks.

CAMERA FLEW TO THE NORTH POLE on globally dispersed selections. Fits now trim outliers,
reject invalid and null-island coordinates, and unwrap longitudes across the antimeridian,
so the view lands on the data instead of a pole.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 12:50:37 +00:00
parent d085a541ca
commit 44dd2d01f3
25 changed files with 424 additions and 140 deletions

View file

@ -27,15 +27,15 @@ import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
// why the diagnosis rests on the row IDENTITY fingerprint being unchanged too.
//
// NEITHER HALF WAS BROKEN; THE LINK WAS ABSENT. The grid's receiving end already works
// (people-groups-grid.vue:60-64 declares all five props and watches them), and the dashboard
// (people-groups-grid.vue declares the filter props in defineProps and watches them all), and the dashboard
// now emits (`dashboard-filter`). This handler is the translation layer between them, because
// the two speak different vocabularies: the dashboard's bus keys are snake_case facet names
// from dashboard.config.js, the grid's props are camelCase.
//
// ONLY FOUR OF THE DASHBOARD'S TWELVE FACETS MAP. The config defines religion,
// ONLY FIVE OF THE DASHBOARD'S TWELVE FACETS MAP. The config defines religion,
// religion_family, wagf_region, wagf_block, country_code, engagement_status, language_family,
// primary_language, affinity_bloc, cluster, people_group and rop1 — but the grid accepts only
// query/wagfRegion/wagfBlock/rop1/religion. The unmapped eight are deliberately DROPPED rather
// query/wagfRegion/wagfBlock/rop1/religion/countryCode. The unmapped seven are DROPPED rather
// than guessed at: pushing a value into a prop the grid filters differently would silently
// show the WRONG rows, which is worse than the honest current behaviour of not narrowing on
// that axis. Widening this map means adding real props to the grid first.
@ -44,19 +44,38 @@ import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
// `religion_family` is NOT `religion`, even though the names invite it. Measured: clicking the
// facet "Hinduism 693" sets filterSet.religion_family = "Hinduism" (a family NAME, derived in
// dashboard.config.js from the FIRST LETTER of the religion code). The grid's `religion` prop
// matches the religion CODE/value (people-groups-grid.vue:118 → :217). Mapping family→religion
// matches the religion CODE/value (people-groups-grid.vue: propFilters → valueMatches). Mapping family→religion
// would compare "Hinduism" against codes and filter the grid to ZERO rows — a broken screen
// that looks like a data bug. A real religion_family filter needs a matching prop on the grid.
//
// ★ COUNTRY WAS THE FIFTH TO EARN ITS MAPPING (Driver-reported 2026-09-22: "when I click on
// India in the top countries nothing happens and the people groups grid just stays the same").
// That report was correct, and the cause was this map: `country_code` was one of the eight
// dropped facets, so the "Top countries" chart had no prop to reach and the grid could not
// react. The fix was NOT to guess a mapping — it was to add a real `countryCode` prop to the
// grid first, then map onto it, which is the procedure the paragraph above prescribes.
//
// It passes the same test religion_family FAILS. The test is "does the facet filter over the
// SAME field as the prop?":
// religion_family → DERIVED (first letter of the religion code → "Hinduism"); no such field
// exists on a row. Still dropped.
// country_code → dashboard.config.js:80 is `get: (r) => lbl(r.country_code)`, the label of
// the very field the grid's filter reads. Same field, same rows.
// The label-vs-value gap that remains ("India" here, "IND" in the row) is absorbed by the
// grid's shared valueMatches() normaliser, which compares against value AND label. Verified on
// the live 2,107 rows: 175 countries, 100% populated, and zero normalisation collisions between
// any two of them. Seven facets remain deliberately unmapped, on the same rule.
const FACET_TO_PROP = {
wagf_region: 'wagfRegion',
wagf_block: 'wagfBlock',
rop1: 'rop1',
religion: 'religion',
wagf_region: 'wagfRegion',
wagf_block: 'wagfBlock',
rop1: 'rop1',
religion: 'religion',
country_code: 'countryCode',
}
// 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: '' })
const filter = reactive({ query: '', wagfRegion: '', wagfBlock: '', rop1: '', religion: '', countryCode: '' })
function onDashboardFilter({ filterSet = {}, searchTerm = '' } = {}) {
filter.query = searchTerm || ''
@ -126,6 +145,7 @@ const sections = [
:wagf-block="filter.wagfBlock"
:rop1="filter.rop1"
:religion="filter.religion"
:country-code="filter.countryCode"
:show-search="false" :show-filters="false" :show-total="false" />
</template>
</StackLayout>

View file

@ -19,10 +19,15 @@
* - `show-search` / `show-filters` ([Boolean,String], default true) TURN OFF its own
* controls so an external header (e.g. the Big-Picture Cat-4 shell) can drive it.
* - External filter props feed it when the matching control is hidden:
* query, wagfRegion, wagfBlock, rop1 (people-group), religion
* …the FOUR dropdown axes (Region / Block / People-Group / Religion). Country is the
* 5th axis and is EXCLUDED here per Driver. Precedence = internal control (when shown)
* ELSE the incoming prop (local-else-prop).
* query, wagfRegion, wagfBlock, rop1 (people-group), religion, countryCode
* …the four dropdown axes (Region / Block / People-Group / Religion) PLUS Country.
* Precedence = internal control (when shown) ELSE the incoming prop (local-else-prop).
*
* ★ COUNTRY IS PROP-ONLY, NOT A FIFTH DROPDOWN. The Driver's exclusion of Country was
* about this element's OWN CONTROLS — it ships four dropdowns, not five, and that is
* unchanged (see extractFilterOptions + the filters__panel template). What is added is a
* `countryCode` INPUT so a host that already owns a country control (the cap-4
* dashboard's "Top countries" chart) can drive the grid. Nothing new is rendered.
* - Emits `groups-filtered` { query, activeFilters, total, slugs } on every change —
* BOTH a Vue emit AND a native bubbling+composed DOM CustomEvent, so a plain host page
* hears it when the element is embedded standalone.
@ -61,13 +66,50 @@ const props = defineProps({
showTotal: { type: [Boolean, String], default: true },
// ── external filter props (used when the matching internal control is hidden) ──
// THE FOUR axes (Country excluded per Driver): Region / Block / People-Group / Religion.
// The four dropdown axes — Region / Block / People-Group / Religion — plus Country.
query: { type: String, default: '' },
wagfRegion: { type: String, default: '' },
wagfBlock: { type: String, default: '' },
rop1: { type: String, default: '' }, // "People Group" axis (rop1)
religion: { type: String, default: '' },
// ★ COUNTRY — WHY IT WAS DROPPED, AND WHAT MAKES IT SAFE TO MAP NOW.
//
// THE DRIVER'S BUG (reported 2026-09-22): "when I click on India in the top countries nothing
// happens and the people groups grid just stays the same." That was accurate and the cause was
// NOT a wiring fault. The cap-4 composite's FACET_TO_PROP translates dashboard facet keys to
// grid props, and it mapped only four; `country_code` was one of eight facets deliberately
// DROPPED, on the standing rule that it is better to not narrow on an axis than to push a value
// into a prop that filters differently and silently show the WRONG rows. With no country prop
// to land in, a country selection had nowhere to go — so the honest fix is to add the prop
// here FIRST, which is what this is. (See the composite's header for the rule + the
// religion_family worked example.)
//
// WHY THIS ONE IS SAFE, WHERE religion_family IS NOT. The rule the composite states is: never
// map a facet onto a prop that filters over a DIFFERENT field. Measured against that test:
// religion_family — dashboard.config.js DERIVES it (first letter of the religion code →
// "Hinduism"). There is no `religion_family` field on a row at all, so it
// could only be aimed at `religion`, which holds CODES. Different field →
// unsafe → still dropped.
// country_code — dashboard.config.js:80 is `get: (r) => lbl(r.country_code)`. That is the
// LABEL OF THE VERY SAME FIELD this filter reads, `u.country_code`. Same
// field, same 2,107 rows, nothing derived. Only the SPELLING differs.
// So country passes the test on its merits; it is not an exception to the rule.
//
// THE SPELLING DIFFERENCE IS ALREADY HANDLED — see valueMatches() below. MEASURED on the live
// API (2,107 rows, 2026-09-22): country_code is a {value,label} pair with an ALPHA-3 machine
// value and a human label — {"value":"IND","label":"India"} — across 175 distinct countries,
// populated on 100% of rows (zero nulls). The dashboard emits the LABEL ("India"); the row
// stores "IND". A strict === would filter to zero, which is the same defect the label-vs-value
// fix already addressed for region. Routing country through valueMatches() makes EITHER
// vocabulary work: it normalises and compares against value AND label, so "India", "india" and
// "IND" all select the same 855 rows.
//
// AND THE WIDENING IS NOT OVER-WIDE. norm() strips case and [\s_&-], so the fair question is
// whether two DIFFERENT countries can collapse onto one key and over-match. Checked over all
// 175 values and all 175 labels: ZERO collisions. Country is safe on both sides of the match.
countryCode: { type: String, default: '' },
// ── presentation knobs copied from UupgsList (defaults preserve today's behaviour) ──
perPage: { type: Number, default: 24 },
morePerPage: { type: Number, default: 0 },
@ -121,6 +163,10 @@ const propFilters = computed(() => {
if (props.wagfBlock) f.wagf_block = { value: props.wagfBlock, label: lbl(props.wagfBlock) }
if (props.rop1) f.rop1 = { value: props.rop1, label: lbl(props.rop1) }
if (props.religion) f.religion = { value: props.religion, label: lbl(props.religion) }
// The key MUST be the API field name `country_code`, not the prop name: applyDropdownFilters
// indexes the row with it directly (u[field]). camelCase here would read u.countryCode, which
// is undefined on every row, and filter the grid to zero.
if (props.countryCode) f.country_code = { value: props.countryCode, label: lbl(props.countryCode) }
return f
})
const activeFilters = computed(() => (useFilters.value ? localFilters.value : propFilters.value))
@ -187,14 +233,43 @@ function toggleExact() {
}
// ── copied from UupgsList: dropdown filtering + faceted counts + Fuse search ────
// TWO VOCABULARIES MEET HERE, AND A STRICT === SILENTLY FILTERED TO ZERO.
//
// THE BUG (Driver-reported + measured live 2026-09-22, dashboard-plus-grid leaf). Picking a
// region in the dashboard above showed "1,309" in its own chart while the grid below said
// "no people groups match this filter" — every host-driven selection produced ZERO cards.
// Neither half was broken and the event wiring was correct; the two sides simply SPELL the
// same value differently:
// the dashboard emits the human LABEL ...... "Asia", "North America & non-Spanish Caribbean"
// the API stores the machine VALUE ......... "asia", "north_america_&_non-spanish_caribbean"
// `vl?.value === selection.value` compares those literally, so it never matched.
//
// ★ WHY THE GRID'S OWN DROPDOWNS ALWAYS WORKED — and why no gate caught this. Those options
// are built by extractFilterOptions() FROM THE DATA ITSELF, so that path compares "asia" to
// "asia" and can never mismatch. Only the HOST-PROP path (see the props→activeFilters bridge
// above, which wraps an incoming prop verbatim) can inject a label into this comparison. The
// defect therefore exists ONLY in the composite, which is why the standalone grid is fine and
// verify:gate1 passes 13/13 on the same 2,107 records.
//
// THE FIX IS A NORMALISING MATCH, NOT A TRANSLATION TABLE. A lookup mapping label→value would
// need maintaining for every axis and would rot the moment the API adds a region. Instead
// compare both the value AND the label, case- and separator-insensitively, so EITHER
// 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, '')
function valueMatches(vl, selection) {
if (!vl) return false
const want = norm(selection?.value)
if (!want) return false
return norm(vl.value) === want || norm(vl.label) === want
}
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) => {
const vl = u[field]
return vl?.value === selection.value
})
filtered = filtered.filter((u) => valueMatches(u[field], selection))
}
}
return filtered
@ -214,7 +289,17 @@ function extractFilterOptions() {
.map(([value, { label, count }]) => ({ value, label, count }))
.sort((a, b) => a.label.localeCompare(b.label))
}
// The FOUR axes only (Country excluded per Driver).
// THE FOUR AXES ONLY — Country is deliberately absent here, and that is not an oversight.
// filterOptions is what RENDERS the dropdowns (see the filters__panel template), so adding
// country_code would grow this element a FIFTH dropdown, which is exactly the control the
// Driver excluded. The new `countryCode` prop is an INPUT, not a control: it reaches the rows
// through propFilters → activeFilters → applyDropdownFilters without ever passing through here.
//
// The cross-filtering still comes out right. updateFilterOptionCounts() below iterates
// Object.keys(filterOptions) for the fields it RECOMPUTES, but feeds applyWith() the whole of
// activeFilters minus that one field — so an incoming country selection DOES narrow the four
// visible counts (pick India and Religion re-counts over 855 rows, not 2,107), it simply never
// gets a picker of its own. Input, not control: that is the whole distinction.
filterOptions.value = {
wagf_region: extract('wagf_region'),
wagf_block: extract('wagf_block'),
@ -245,11 +330,14 @@ function updateFilterOptionCounts() {
}
// helper: apply a specific filter-set (used by the faceted-count cross-filter)
// Uses the same valueMatches() as applyDropdownFilters: if the two disagreed, the facet COUNTS
// would contradict the rows actually shown (a chart reading 1,309 above an empty list — exactly
// the symptom the Driver reported). One matcher, both call sites.
function applyWith(filters, list) {
let filtered = list
for (const [field, selection] of Object.entries(filters)) {
if (field === 'exact') continue
filtered = filtered.filter((u) => u[field]?.value === selection.value)
filtered = filtered.filter((u) => valueMatches(u[field], selection))
}
return filtered
}
@ -394,7 +482,7 @@ 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, useSearch.value, useFilters.value], () => {
watch(() => [props.query, props.wagfRegion, props.wagfBlock, props.rop1, props.religion, props.countryCode, useSearch.value, useFilters.value], () => {
page.value = 1
filterUUPGs()
})

View file

@ -299,7 +299,9 @@ useShadowStyles(`
/* gap:0 so consecutive trunk spans butt flush and their 1px rules form ONE continuous line
(an 8px flex gap would break each level's rule into a dashed ladder). The label/bar/count
spacing that the gap used to provide is restored by margin-left on .fr-lbl below. */
.fr-row{ position:relative; display:flex; align-items:center; gap:0; padding:3px 12px 3px 14px; cursor:pointer; font-size:12px; color:var(--dx-text, #3b463d); }
/* min-height replaces an implicit one-line height so a WRAPPED label (see .fr-lbl) grows the
row instead of overflowing it; single-line rows keep their previous rhythm. */
.fr-row{ position:relative; display:flex; align-items:center; gap:0; padding:3px 12px 3px 14px; min-height:22px; cursor:pointer; font-size:12px; color:var(--dx-text, #3b463d); }
.fr-row:hover{ background:var(--dx-surface, #f3f3f1); }
/* Indentation is carried by the TRUNK SPANS (one 12px span per ancestor level), not by
padding-left — so depth is drawn as a connected tree instead of implied by whitespace.
@ -338,8 +340,23 @@ useShadowStyles(`
.fr-sw{ flex:0 0 auto; width:9px; height:9px; border-radius:2px; margin-left:4px;
box-shadow:inset 0 0 0 1px rgba(59,70,61,.18); }
/* re-introduce the inter-column spacing the row's gap used to supply (trunks stay flush) */
.fr-lbl{ flex:0 1 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-left:6px; }
.fr-bar{ flex:1 1 auto; height:6px; min-width:24px; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; margin-left:8px; }
/* WRAP, DON'T ELLIPSISE — the COUNT is the information, and nowrap was hiding it.
Driver-reported 2026-09-22: "North America and non-Spanish Caribbean is way too long... I
can't even scroll right to see the numbers", same for "Chinese folk religion". The old rule
was white-space:nowrap + overflow:hidden + text-overflow:ellipsis, which let a long label
eat the row; because .fr-body is overflow-x:hidden (vertical scroll only, by design), there
was NO way to scroll right and recover the number. An ellipsis hid the label AND cost the count.
Wrapping to a second line keeps both readable at the rail's fixed width.
overflow-wrap:anywhere (not break-all) breaks only when a word genuinely cannot fit, so
ordinary labels still break at spaces. The trunk glyphs stay aligned because .fr-trunk is
align-self:stretch — it grows with the row instead of assuming one text line. */
.fr-lbl{ flex:1 1 auto; min-width:0; white-space:normal; overflow-wrap:anywhere; line-height:1.3; margin-left:6px; }
/* The bar YIELDS to the label now that the label wraps (flex:1 1 auto above). Previously both
were flex:1 1 auto and the bar's 24px floor competed with the text for the same row; with a
wrapping label the bar must be the element that gives way, so it is capped and allowed to
shrink to zero-ish on a narrow rail. The COUNT (.fr-cnt, flex:0 0 auto) never shrinks — it is
the number the Driver could not previously see. */
.fr-bar{ flex:0 1 56px; height:6px; min-width:0; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; margin-left:8px; }
/* magnitude fill — a QUANTITY bar, not a categorical series, so it takes the sage accent
rather than a hue of its own (the pale blue here was the last of the drift). */
.fr-bar i{ display:block; height:100%; background:var(--dx-accent, #92b195); border-radius:3px; transition:width .3s; }

View file

@ -425,18 +425,177 @@ function toggleProjection() { projection.value = projection.value === 'globe' ?
// ── RDASH-ENHANCE: fly-to on selection. Any facet brush (tree node / chart slice / region / row)
// flies the map to fit the in-scope people groups; a pin focus flies to that PG (deeper drill). ──
function boundsOf(rows) {
const pts = rows.filter((r) => Number(r.latitude) && Number(r.longitude))
if (!pts.length) return null
let w = 180, s = 90, e = -180, n = -90
for (const r of pts) { const x = Number(r.longitude), y = Number(r.latitude); if (x < w) w = x; if (x > e) e = x; if (y < s) s = y; if (y > n) n = y }
return [[w, s], [e, n]]
//
// ╔═ BUG-CAMERA-FLIES-TO-NORTH-POLE-ON-FLAT-FACETS (Driver, 2026-09-22) ════════════════════╗
// "when I click unengaged status it just goes to the north pole. Deaf people, it just goes to
// the north pole... it needs to take you to the highest concentration location geographically,
// not to the North Pole."
//
// ── WHAT WAS MEASURED (live API, 2,107 rows, pray.doxa.life, 2026-09-22) ──
// The old code fitBounds()'d the raw min/max bbox of the in-scope rows. For the Unengaged
// facet (2,080 of 2,107 rows) that bbox measured:
// W -176.18 S -36.85 E 178.48 N 78.22 → 354.7 deg of lng, 115.1 deg of lat
// Run through the REAL MapLibre 5.6 in a real WebGL browser, fitBounds resolved that box to:
// center [1.15, 41.15] zoom 2.06 (globe projection; identical center under mercator)
// and map.project([lng, 90]) put the NORTH POLE at screen y=99 in an 800px-tall viewport —
// i.e. the pole is genuinely in frame, occupying the top third, which is precisely what the
// Driver is describing. The densest data cell (75E/15N) was shoved to x=939, onto the right
// limb of the globe where the sphere foreshortens it into an unreadable sliver.
//
// ── WHAT THE BUG IS *NOT* (both hypotheses tested and DISPROVED) ──
// 1. NOT a degenerate/empty-bounds fallback to something like [0, 90]. fitBounds threw no
// error, hit no fallback, and returned a perfectly valid finite center. cameraForBounds()
// agreed with fitBounds() exactly. There is no degenerate path being taken here at all.
// 2. NOT "the bbox center is the pole". The bbox center latitude is 20.69 — nowhere near a
// pole. The camera still ends up at 41.15.
//
// ── THE ACTUAL ROOT CAUSE (two effects stacking) ──
// a. A min/max bbox is an OUTLIER statistic. 61 of 2,080 unengaged rows (2.9%) lie north of
// 41N, and a couple reach 78.22N — yet those few rows drag the entire frame ~23 deg north.
// The MEDIAN unengaged row sits at 18.08N. The camera was framing the tail, not the mass.
// b. Vertical fitting is NON-LINEAR in latitude (Mercator-family y). So the northern half of
// a tall box eats disproportionate screen height, pulling the resolved center (41.15) well
// north of the box's own midpoint (20.69). On the GLOBE projection — which is the panel's
// default (see `projection` above, initialised to 'globe') — that northward tilt is what
// swings the pole into view and rolls the equatorial data mass off to the limb. This is
// why the Driver tied it to the globe/flat setting: flat shows the same bad center, but a
// sphere makes it look like you flew to the Arctic.
//
// ── THE FIX: frame the CONCENTRATION, and make a pole unreachable by construction ──
// We fit a PERCENTILE-TRIMMED box (P10..P90 on each axis) instead of the raw extent, and we
// anchor the camera on the MEDIAN point. Measured on the same live rows, trimming collapses
// the latitude span from 115.1 to 32.8 deg, and the resulting fit centers at [45.91, 16.87]
// with the north pole at screen y = -138 — OFF-SCREEN. Verified by rendering the real 2,080
// points: the South-Asia cluster is large and legible and no pole appears.
//
// Why the MEDIAN as the anchor: of the candidates measured for "highest concentration",
// median lng 75.25 -> 73% of rows within +/-45 deg ← best
// 3x3 densest-bin centroid 78.51 -> 70%
// densest 15-deg bin 82.50 -> 68%
// trimmed midpoint 45.91 -> 68%
// The median also wins on properties, not just this dataset: it is the SAME cheap statistic on
// both axes, it is by definition an actual data latitude, and it therefore CANNOT be a pole
// unless a majority of the selection genuinely sits at a pole. That is the structural guarantee
// the card asked for — the degenerate path cannot resolve to a pole because the pole is never
// a candidate value, rather than being fenced off by an after-the-fact clamp.
//
// ── WHAT WAS REJECTED ──
// • Clamping the resolved center latitude (e.g. to +/-60). Treats the symptom: the camera
// would stop showing the pole but would still be framing the outlier tail rather than the
// concentration, so "deaf people" would land in empty ocean instead of empty Arctic.
// • A k-means / DBSCAN densest-cluster search. Genuinely better framing for multi-lobe sets,
// but it is O(n*k*i) on every facet click, non-deterministic without a seed, and this runs
// on a 2,107-row set behind a 260ms debounce in a dashboard cell. Percentile trim + median
// is one sort per axis, fully deterministic, and measured sufficient.
// • Reusing useMapFly's _robustLngExtent (which already does trim + densest-bin). It solves
// only LONGITUDE — its latitude handling is still (minLat+maxLat)/2, i.e. exactly the
// outlier-driven statistic that causes THIS bug. It is also module-private. Fixing the
// library's latitude path is the right follow-up, but it is a shared-surface change across
// every map profile and does not belong in a P0 panel fix.
//
// Antimeridian: the trim is applied to longitudes unwrapped at their largest circular gap, so
// a Pacific set holding both -176 and +178 reads as one contiguous ~6-deg span rather than a
// false 355-deg span. Same technique the library uses; see useMapFly.js _robustLngExtent.
// ╚═════════════════════════════════════════════════════════════════════════════════════════╝
// Fraction trimmed off EACH tail before framing. 0.10 was chosen against the live data: it is
// aggressive enough to drop the Arctic tail that caused this bug (115.1 -> 32.8 deg of lat) while
// still keeping 80% of the selection inside the frame. Anything visible outside the box is not
// hidden — it simply does not get a vote on where the camera points.
const FIT_TRIM = 0.10
// Sanity floor: trimming is pointless (and can over-crop) on a handful of rows, where every row
// is already "the concentration". Below this we frame the full extent.
const FIT_TRIM_MIN_ROWS = 12
// Usable [lng, lat] for a row, or null. Number.isFinite — NOT truthiness: the old code's
// `Number(r.latitude) && Number(r.longitude)` silently discarded every row sitting exactly on
// the equator or the prime meridian. Rejects only the exact 0,0 pair, which is the null-island
// sentinel a failed geocode degrades into, plus out-of-range junk.
function coordOf(r) {
const x = Number(r.longitude), y = Number(r.latitude)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
if (x === 0 && y === 0) return null
if (y < -90 || y > 90 || x < -180 || x > 180) return null
return [x, y]
}
// Value at fraction p of a sorted numeric array (p=0.5 is the median). Nearest-rank, so the
// result is always an ACTUAL observed value — never an interpolated coordinate that no row
// occupies, which is what keeps the median anchor physically meaningful.
function pctl(sorted, p) {
if (!sorted.length) return NaN
const i = Math.min(sorted.length - 1, Math.max(0, Math.round(p * (sorted.length - 1))))
return sorted[i]
}
// Longitudes unwrapped at their largest circular gap, so a set straddling the antimeridian
// becomes monotonic and its span/percentiles are meaningful. Returns values that may exceed
// 180; fitBounds accepts and wraps those correctly.
function unwrapLngs(xs) {
const s = xs.slice().sort((a, b) => a - b)
const n = s.length
if (n < 2) return s
let gap = -1, gapIdx = 0
for (let i = 0; i < n; i++) {
const next = (i + 1 < n) ? s[i + 1] : s[0] + 360
const g = next - s[i]
if (g > gap) { gap = g; gapIdx = i }
}
const start = (gapIdx + 1) % n
const out = new Array(n)
for (let k = 0; k < n; k++) {
let v = s[(start + k) % n]
if (k > 0 && v < out[k - 1]) v += 360
out[k] = v
}
return out
}
/**
* Camera target for a set of rows, framed on where the rows CONCENTRATE.
*
* @returns {null | { bounds:[[w,s],[e,n]], center:[lng,lat] }}
* null when no row has usable coordinates — the caller must then hold position.
* `center` is the median point and is always an anchor the fit is re-centred on, so
* a lopsided (multi-lobe) selection still points at its densest lobe.
*/
function boundsOf(rows) {
const pts = []
for (const r of rows) { const c = coordOf(r); if (c) pts.push(c) }
if (!pts.length) return null
const lats = pts.map((p) => p[1]).sort((a, b) => a - b)
const lngs = unwrapLngs(pts.map((p) => p[0]))
// Below the floor, or for a single point, trimming would throw away real signal.
const t = pts.length >= FIT_TRIM_MIN_ROWS ? FIT_TRIM : 0
const s = pctl(lats, t), n = pctl(lats, 1 - t)
const w = pctl(lngs, t), e = pctl(lngs, 1 - t)
const center = [pctl(lngs, 0.5), pctl(lats, 0.5)]
// Degenerate box (one point, or a co-located cluster): fitBounds would resolve it at maxZoom.
// Hand back a zero-area box centred on the median and let the caller's maxZoom cap the zoom.
if (!(e > w) || !(n > s)) return { bounds: [[center[0], center[1]], [center[0], center[1]]], center }
return { bounds: [[w, s], [e, n]], center }
}
function flyToFiltered() {
const m = map.value; if (!m) return
if (!Object.keys(filterSet).length) { try { m.flyTo({ center: [28, 18], zoom: 1.45, duration: 1000 }) } catch (_) {} ; return }
const b = boundsOf(filteredRows.value)
if (b) { try { m.fitBounds(b, { padding: 70, duration: 1200, maxZoom: 5.5 }) } catch (_) {} }
const t = boundsOf(filteredRows.value)
// No usable coordinates in scope: HOLD POSITION. Deliberately not a reset-to-world — an
// unrequested snap-back is motion the user did not ask for (same rule useSelectionCamera
// follows for an empty selection).
if (!t) return
try {
m.fitBounds(t.bounds, { padding: 70, duration: 1200, maxZoom: 5.5 })
// Re-anchor on the median AFTER the fit. fitBounds derives its center from the box, which
// for a two-lobe selection (e.g. West Africa + South Asia) is the empty gap between the
// lobes; setCenter moves the camera onto the mass without changing the zoom the fit chose.
// Safe against the pole by construction: the median latitude is an observed row latitude.
m.setCenter(t.center)
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
}
let _flyT = null
watch(() => JSON.stringify(filterSet), () => { clearTimeout(_flyT); _flyT = setTimeout(flyToFiltered, 260) })

View file

@ -1 +1 @@
pid=3497423 started=2026-09-22T11:45:46Z by=lk10x
pid=3840928 started=2026-09-22T12:45:39Z by=lk10x

File diff suppressed because one or more lines are too long

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-soRngAClVe33aZ0x9qX5YGVjmOCWml6J6p7h6ZYAFgGI+fpWolApFo/Y3UZYlMBi" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-T+pRaZkssXjuFk1ehjya7I73/AmZzZs41RPQ8PvujPFGJuVI53fcJu9+3Zk28n02" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-soRngAClVe33aZ0x9qX5YGVjmOCWml6J6p7h6ZYAFgGI+fpWolApFo/Y3UZYlMBi" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-T+pRaZkssXjuFk1ehjya7I73/AmZzZs41RPQ8PvujPFGJuVI53fcJu9+3Zk28n02" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../big-picture.js" integrity="sha384-soRngAClVe33aZ0x9qX5YGVjmOCWml6J6p7h6ZYAFgGI+fpWolApFo/Y3UZYlMBi" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-T+pRaZkssXjuFk1ehjya7I73/AmZzZs41RPQ8PvujPFGJuVI53fcJu9+3Zk28n02" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -130,7 +130,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"big-picture","hrefBase":"../"};
/* file:// contract: manifest inlined at build — no fetch needed to render. */
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"big-picture","scriptUrl":"./big-picture/big-picture.js","output":"doxa-maps/big-picture/big-picture.js","integrity":"sha384-soRngAClVe33aZ0x9qX5YGVjmOCWml6J6p7h6ZYAFgGI+fpWolApFo/Y3UZYlMBi","elements":["bp-big-picture","bp-research-header","people-groups-grid"],"profiles":[{"profile":"big-picture","file":"big-picture.vue","description":"The Cat-4 composite that PAIRS the two real capability elements of the","element":"bp-big-picture","type":"nested-elements-mfe","isMap":false,"iframeUrl":"./big-picture/big-picture/index.html","scriptUrl":"./big-picture/big-picture.js","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./big-picture/big-picture.js\" integrity=\"sha384-soRngAClVe33aZ0x9qX5YGVjmOCWml6J6p7h6ZYAFgGI+fpWolApFo/Y3UZYlMBi\" crossorigin=\"anonymous\">\u003c/script>\n\u003cbp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/bp-big-picture>"},"instances":[{"id":"big-picture","title":"Big Picture — research header ↔ people-groups grid (composite)","apiBaseUrl":"","props":{"profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture"},"element":"bp-big-picture","iframeUrl":"./big-picture/big-picture/big-picture/index.html","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"frontier","title":"Big Picture — frontier accent","apiBaseUrl":"","props":{"profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture-frontier","accent":"#22d3ee","title":"Big Picture — Frontier Focus"},"element":"bp-big-picture","iframeUrl":"./big-picture/big-picture/frontier/index.html","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/frontier/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}],"bundle":"big-picture"}]}]};</script>
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"big-picture","scriptUrl":"./big-picture/big-picture.js","output":"doxa-maps/big-picture/big-picture.js","integrity":"sha384-T+pRaZkssXjuFk1ehjya7I73/AmZzZs41RPQ8PvujPFGJuVI53fcJu9+3Zk28n02","elements":["bp-big-picture","bp-research-header","people-groups-grid"],"profiles":[{"profile":"big-picture","file":"big-picture.vue","description":"The Cat-4 composite that PAIRS the two real capability elements of the","element":"bp-big-picture","type":"nested-elements-mfe","isMap":false,"iframeUrl":"./big-picture/big-picture/index.html","scriptUrl":"./big-picture/big-picture.js","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./big-picture/big-picture.js\" integrity=\"sha384-T+pRaZkssXjuFk1ehjya7I73/AmZzZs41RPQ8PvujPFGJuVI53fcJu9+3Zk28n02\" crossorigin=\"anonymous\">\u003c/script>\n\u003cbp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/bp-big-picture>"},"instances":[{"id":"big-picture","title":"Big Picture — research header ↔ people-groups grid (composite)","apiBaseUrl":"","props":{"profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture"},"element":"bp-big-picture","iframeUrl":"./big-picture/big-picture/big-picture/index.html","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"frontier","title":"Big Picture — frontier accent","apiBaseUrl":"","props":{"profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture-frontier","accent":"#22d3ee","title":"Big Picture — Frontier Focus"},"element":"bp-big-picture","iframeUrl":"./big-picture/big-picture/frontier/index.html","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/frontier/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}],"bundle":"big-picture"}]}]};</script>
<script>/**
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
*

File diff suppressed because one or more lines are too long

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-/UkaMDPGJO4ukE+I+epQnPGG48NER9WEhjeiQyWIfGFr6zYZB+tLg4aVnBHwzxfn" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-9BYJSq79mr0iTDOZUk48P35+bpXCVJke9xD5W2xYvKPnQAE/rbpNdxaZzkiZjnSj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-/UkaMDPGJO4ukE+I+epQnPGG48NER9WEhjeiQyWIfGFr6zYZB+tLg4aVnBHwzxfn" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-9BYJSq79mr0iTDOZUk48P35+bpXCVJke9xD5W2xYvKPnQAE/rbpNdxaZzkiZjnSj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -130,7 +130,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"dashboard-plus-grid","hrefBase":"../"};
/* file:// contract: manifest inlined at build — no fetch needed to render. */
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"dashboard-plus-grid","scriptUrl":"./dashboard-plus-grid/dashboard-plus-grid.js","output":"doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js","integrity":"sha384-/UkaMDPGJO4ukE+I+epQnPGG48NER9WEhjeiQyWIfGFr6zYZB+tLg4aVnBHwzxfn","elements":["dashboard-plus-grid"],"profiles":[{"profile":"dashboard-plus-grid","file":"dashboard-plus-grid.vue","description":"Both children are IMPORTED DIRECTLY (compiled into this ONE bundle — no runtime bundle","element":"dashboard-plus-grid","type":"nested-elements-mfe","isMap":true,"iframeUrl":"./dashboard-plus-grid/dashboard-plus-grid/index.html","scriptUrl":"./dashboard-plus-grid/dashboard-plus-grid.js","embed":{"iframe":"\u003ciframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-/UkaMDPGJO4ukE+I+epQnPGG48NER9WEhjeiQyWIfGFr6zYZB+tLg4aVnBHwzxfn\" crossorigin=\"anonymous\">\u003c/script>\n\u003cdashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/dashboard-plus-grid>"},"instances":[{"id":"default","title":"default","apiBaseUrl":"","props":{"profile":"dashboard-plus-grid"},"element":"dashboard-plus-grid","iframeUrl":"./dashboard-plus-grid/dashboard-plus-grid/default/index.html","embed":{"iframe":"\u003ciframe src=\"./dashboard-plus-grid/dashboard-plus-grid/default/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}]}]}]};</script>
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"dashboard-plus-grid","scriptUrl":"./dashboard-plus-grid/dashboard-plus-grid.js","output":"doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js","integrity":"sha384-9BYJSq79mr0iTDOZUk48P35+bpXCVJke9xD5W2xYvKPnQAE/rbpNdxaZzkiZjnSj","elements":["dashboard-plus-grid"],"profiles":[{"profile":"dashboard-plus-grid","file":"dashboard-plus-grid.vue","description":"Both children are IMPORTED DIRECTLY (compiled into this ONE bundle — no runtime bundle","element":"dashboard-plus-grid","type":"nested-elements-mfe","isMap":true,"iframeUrl":"./dashboard-plus-grid/dashboard-plus-grid/index.html","scriptUrl":"./dashboard-plus-grid/dashboard-plus-grid.js","embed":{"iframe":"\u003ciframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-9BYJSq79mr0iTDOZUk48P35+bpXCVJke9xD5W2xYvKPnQAE/rbpNdxaZzkiZjnSj\" crossorigin=\"anonymous\">\u003c/script>\n\u003cdashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/dashboard-plus-grid>"},"instances":[{"id":"default","title":"default","apiBaseUrl":"","props":{"profile":"dashboard-plus-grid"},"element":"dashboard-plus-grid","iframeUrl":"./dashboard-plus-grid/dashboard-plus-grid/default/index.html","embed":{"iframe":"\u003ciframe src=\"./dashboard-plus-grid/dashboard-plus-grid/default/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}]}]}]};</script>
<script>/**
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
*

File diff suppressed because one or more lines are too long

View file

@ -35,7 +35,7 @@
"bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js",
"output": "doxa-maps/big-picture/big-picture.js",
"integrity": "sha384-soRngAClVe33aZ0x9qX5YGVjmOCWml6J6p7h6ZYAFgGI+fpWolApFo/Y3UZYlMBi",
"integrity": "sha384-T+pRaZkssXjuFk1ehjya7I73/AmZzZs41RPQ8PvujPFGJuVI53fcJu9+3Zk28n02",
"elements": [
"bp-big-picture",
"bp-research-header",
@ -53,7 +53,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-soRngAClVe33aZ0x9qX5YGVjmOCWml6J6p7h6ZYAFgGI+fpWolApFo/Y3UZYlMBi\" 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-T+pRaZkssXjuFk1ehjya7I73/AmZzZs41RPQ8PvujPFGJuVI53fcJu9+3Zk28n02\" 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": [
{
@ -189,7 +189,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-/UkaMDPGJO4ukE+I+epQnPGG48NER9WEhjeiQyWIfGFr6zYZB+tLg4aVnBHwzxfn",
"integrity": "sha384-9BYJSq79mr0iTDOZUk48P35+bpXCVJke9xD5W2xYvKPnQAE/rbpNdxaZzkiZjnSj",
"elements": [
"dashboard-plus-grid"
],
@ -205,7 +205,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-/UkaMDPGJO4ukE+I+epQnPGG48NER9WEhjeiQyWIfGFr6zYZB+tLg4aVnBHwzxfn\" 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-9BYJSq79mr0iTDOZUk48P35+bpXCVJke9xD5W2xYvKPnQAE/rbpNdxaZzkiZjnSj\" 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": [
{
@ -660,7 +660,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-JCol/vri0hsYL5ziRSlphv7jLoyhu5ZS5Ll1gREPmX0gs4zw1jmLpdIVbxqAAMie",
"integrity": "sha384-iZt0B4Frfb7tLlAdWioHoQ6m0sXu+SbVqXC1tcbJifuCQmR8zwMQ6STlFLD6MHcU",
"elements": [
"people-groups-grid"
],
@ -676,7 +676,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-JCol/vri0hsYL5ziRSlphv7jLoyhu5ZS5Ll1gREPmX0gs4zw1jmLpdIVbxqAAMie\" 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-iZt0B4Frfb7tLlAdWioHoQ6m0sXu+SbVqXC1tcbJifuCQmR8zwMQ6STlFLD6MHcU\" 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": [
{
@ -764,7 +764,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "doxa-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-O3Sw2lYVAFvyHkhXQeKtTEmR5DpoDH3zdL7bI8pmu9pNgiFjknHLNJAeMO4UGsSv",
"integrity": "sha384-bkHqLMT1g4ABMX7BhgqM2DEw4luXhZpmCQ/Ya5wDnrzT0bTMJErJ7g5cDITPvCVb",
"elements": [
"research-dashboard-map"
],
@ -780,7 +780,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-O3Sw2lYVAFvyHkhXQeKtTEmR5DpoDH3zdL7bI8pmu9pNgiFjknHLNJAeMO4UGsSv\" 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-bkHqLMT1g4ABMX7BhgqM2DEw4luXhZpmCQ/Ya5wDnrzT0bTMJErJ7g5cDITPvCVb\" 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"
}

View file

@ -130,7 +130,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"people-groups-grid","hrefBase":"../"};
/* file:// contract: manifest inlined at build — no fetch needed to render. */
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"people-groups-grid","scriptUrl":"./people-groups-grid/people-groups-grid.js","output":"doxa-maps/people-groups-grid/people-groups-grid.js","integrity":"sha384-JCol/vri0hsYL5ziRSlphv7jLoyhu5ZS5Ll1gREPmX0gs4zw1jmLpdIVbxqAAMie","elements":["people-groups-grid"],"profiles":[{"profile":"people-groups-grid","file":"people-groups-grid.vue","description":"The people-groups grid as an owned custom element: card list, search and four","element":"people-groups-grid","type":"parameterized","isMap":false,"iframeUrl":"./people-groups-grid/people-groups-grid/index.html","scriptUrl":"./people-groups-grid/people-groups-grid.js","embed":{"iframe":"\u003ciframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-JCol/vri0hsYL5ziRSlphv7jLoyhu5ZS5Ll1gREPmX0gs4zw1jmLpdIVbxqAAMie\" crossorigin=\"anonymous\">\u003c/script>\n\u003cpeople-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/people-groups-grid>"},"instances":[{"id":"people-groups-grid","title":"People Groups Grid — standalone (own search + 4 dropdowns)","apiBaseUrl":"","props":{"profile":"people-groups-grid","provider":"maplibre","instanceId":"people-groups-grid","pray-base-url":"https://pray.doxa.life"},"element":"people-groups-grid","iframeUrl":"./people-groups-grid/people-groups-grid/people-groups-grid/index.html","embed":{"iframe":"\u003ciframe src=\"./people-groups-grid/people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"driven","title":"People Groups Grid — externally driven (controls off, props/events)","apiBaseUrl":"","props":{"profile":"people-groups-grid","provider":"maplibre","instanceId":"people-groups-grid-driven","pray-base-url":"https://pray.doxa.life","show-search":"false","show-filters":"false"},"element":"people-groups-grid","iframeUrl":"./people-groups-grid/people-groups-grid/driven/index.html","embed":{"iframe":"\u003ciframe src=\"./people-groups-grid/people-groups-grid/driven/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}]}]}]};</script>
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"people-groups-grid","scriptUrl":"./people-groups-grid/people-groups-grid.js","output":"doxa-maps/people-groups-grid/people-groups-grid.js","integrity":"sha384-iZt0B4Frfb7tLlAdWioHoQ6m0sXu+SbVqXC1tcbJifuCQmR8zwMQ6STlFLD6MHcU","elements":["people-groups-grid"],"profiles":[{"profile":"people-groups-grid","file":"people-groups-grid.vue","description":"The people-groups grid as an owned custom element: card list, search and four","element":"people-groups-grid","type":"parameterized","isMap":false,"iframeUrl":"./people-groups-grid/people-groups-grid/index.html","scriptUrl":"./people-groups-grid/people-groups-grid.js","embed":{"iframe":"\u003ciframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-iZt0B4Frfb7tLlAdWioHoQ6m0sXu+SbVqXC1tcbJifuCQmR8zwMQ6STlFLD6MHcU\" crossorigin=\"anonymous\">\u003c/script>\n\u003cpeople-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/people-groups-grid>"},"instances":[{"id":"people-groups-grid","title":"People Groups Grid — standalone (own search + 4 dropdowns)","apiBaseUrl":"","props":{"profile":"people-groups-grid","provider":"maplibre","instanceId":"people-groups-grid","pray-base-url":"https://pray.doxa.life"},"element":"people-groups-grid","iframeUrl":"./people-groups-grid/people-groups-grid/people-groups-grid/index.html","embed":{"iframe":"\u003ciframe src=\"./people-groups-grid/people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"driven","title":"People Groups Grid — externally driven (controls off, props/events)","apiBaseUrl":"","props":{"profile":"people-groups-grid","provider":"maplibre","instanceId":"people-groups-grid-driven","pray-base-url":"https://pray.doxa.life","show-search":"false","show-filters":"false"},"element":"people-groups-grid","iframeUrl":"./people-groups-grid/people-groups-grid/driven/index.html","embed":{"iframe":"\u003ciframe src=\"./people-groups-grid/people-groups-grid/driven/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}]}]}]};</script>
<script>/**
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
*

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-JCol/vri0hsYL5ziRSlphv7jLoyhu5ZS5Ll1gREPmX0gs4zw1jmLpdIVbxqAAMie" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-iZt0B4Frfb7tLlAdWioHoQ6m0sXu+SbVqXC1tcbJifuCQmR8zwMQ6STlFLD6MHcU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../people-groups-grid.js" integrity="sha384-JCol/vri0hsYL5ziRSlphv7jLoyhu5ZS5Ll1gREPmX0gs4zw1jmLpdIVbxqAAMie" crossorigin="anonymous"></script>
<script src="../people-groups-grid.js" integrity="sha384-iZt0B4Frfb7tLlAdWioHoQ6m0sXu+SbVqXC1tcbJifuCQmR8zwMQ6STlFLD6MHcU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-JCol/vri0hsYL5ziRSlphv7jLoyhu5ZS5Ll1gREPmX0gs4zw1jmLpdIVbxqAAMie" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-iZt0B4Frfb7tLlAdWioHoQ6m0sXu+SbVqXC1tcbJifuCQmR8zwMQ6STlFLD6MHcU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -130,7 +130,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"research-dashboard","hrefBase":"../"};
/* file:// contract: manifest inlined at build — no fetch needed to render. */
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"research-dashboard","scriptUrl":"./research-dashboard/research-dashboard.js","output":"doxa-maps/research-dashboard/research-dashboard.js","integrity":"sha384-O3Sw2lYVAFvyHkhXQeKtTEmR5DpoDH3zdL7bI8pmu9pNgiFjknHLNJAeMO4UGsSv","elements":["research-dashboard-map"],"profiles":[{"profile":"research-dashboard","file":"research-dashboard.vue","description":"Coordinated-Multiple-Views research dashboard for 10/40-window UPGs (live pray.doxa.life): docked facet rail + map-as-hero + linked table/charts, brushing-and-linking over one in-memory store.","element":"research-dashboard-map","type":"monolithic","isMap":true,"iframeUrl":"./research-dashboard/research-dashboard/index.html","scriptUrl":"./research-dashboard/research-dashboard.js","embed":{"iframe":"\u003ciframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-O3Sw2lYVAFvyHkhXQeKtTEmR5DpoDH3zdL7bI8pmu9pNgiFjknHLNJAeMO4UGsSv\" crossorigin=\"anonymous\">\u003c/script>\n\u003cresearch-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/research-dashboard-map>"},"bundle":"research-dashboard"}]}]};</script>
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":19,"profileCount":28,"bundles":[{"bundle":"research-dashboard","scriptUrl":"./research-dashboard/research-dashboard.js","output":"doxa-maps/research-dashboard/research-dashboard.js","integrity":"sha384-bkHqLMT1g4ABMX7BhgqM2DEw4luXhZpmCQ/Ya5wDnrzT0bTMJErJ7g5cDITPvCVb","elements":["research-dashboard-map"],"profiles":[{"profile":"research-dashboard","file":"research-dashboard.vue","description":"Coordinated-Multiple-Views research dashboard for 10/40-window UPGs (live pray.doxa.life): docked facet rail + map-as-hero + linked table/charts, brushing-and-linking over one in-memory store.","element":"research-dashboard-map","type":"monolithic","isMap":true,"iframeUrl":"./research-dashboard/research-dashboard/index.html","scriptUrl":"./research-dashboard/research-dashboard.js","embed":{"iframe":"\u003ciframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript>window.MAP_APP_API_URL=\"https://pray.doxa.life\";\u003c/script>\n\u003cscript src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-bkHqLMT1g4ABMX7BhgqM2DEw4luXhZpmCQ/Ya5wDnrzT0bTMJErJ7g5cDITPvCVb\" crossorigin=\"anonymous\">\u003c/script>\n\u003cresearch-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'>\u003c/research-dashboard-map>"},"bundle":"research-dashboard"}]}]};</script>
<script>/**
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
*

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long