Add the bounded set as a centre panel, and stop unknown panels becoming the map
The Driver's order for the centre is Language tree, Bounded set, Centered set,
then Map. Tree and Bounded set are here; the centered-set target is not, because
it needs the ring data that is still on another branch. A tab whose panel cannot
be drawn is worse than a tab that is not there yet, so it arrives with its data
rather than ahead of it.
setCentre had to change first, and it was hiding a trap. It read
centre.value = mode === 'tree' ? 'tree' : 'map'
so every id that was not 'tree' resolved to the map. Adding a tab to the strip
without touching the setter would have drawn the map under the new tab's label:
no error, no empty state, just the wrong panel wearing the right name. The panels
are now a list, and an unknown id warns once and falls back to the first — the
same way an unknown people-group list is already handled.
The chart claims one thing, which side of the line a group is on, so position
carries no other meaning: no gradient, no ranking, no distance. The layout is a
pure module so it can be tested without mounting anything, and it is SVG rather
than the map engine because there is no zoom, no camera and no labels here; if it
is ever pointed at the full table the answer is to aggregate, not to reach for
the GPU.
Reached-ness is not in these rows — they describe internet and language access —
so it joins near-neighbors on name and country. A group that does not join is
reported as unknown rather than drawn outside the circle. "We could not match it"
and "it is unreached" are different facts and only one of them is about the
group. If the table itself fails to load the panel says so, because a bounded set
drawn from nothing is a circle with everything outside it, which reads as a claim
rather than an error.
This commit is contained in:
parent
2e62192c27
commit
3f73cf6d9a
4 changed files with 207 additions and 4 deletions
|
|
@ -31,6 +31,7 @@ import { useBoard } from './src/useBoard.js'
|
|||
import { useGiT } from './src/useGiT.js'
|
||||
import GiFacetRail from './src/components/GiFacetRail.vue'
|
||||
import GiMap from './src/components/GiMap.vue'
|
||||
import GiBoundedSet from './src/components/GiBoundedSet.vue'
|
||||
import GiCharts from './src/components/GiCharts.vue'
|
||||
import GiGrid from './src/components/GiGrid.vue'
|
||||
import GiDrawer from './src/components/GiDrawer.vue'
|
||||
|
|
@ -124,10 +125,21 @@ const treeEver = ref(false)
|
|||
// still built a second WebGL context, its tiles and 1,700 pins on every open (it froze /embed-test when
|
||||
// several heavy cards mounted together). Once shown it stays mounted, so switching back is instant.
|
||||
const mapEver = ref(false)
|
||||
watch(() => board.centre.value, (c) => { if (c === 'tree') treeEver.value = true; if (c === 'map') mapEver.value = true }, { immediate: true })
|
||||
const boundedEver = ref(false)
|
||||
watch(() => board.centre.value, (c) => {
|
||||
if (c === 'tree') treeEver.value = true
|
||||
if (c === 'map') mapEver.value = true
|
||||
if (c === 'bounded') boundedEver.value = true
|
||||
}, { immediate: true })
|
||||
// The Driver's order: Language tree, Bounded set, Centered set, then Map. The centered-set
|
||||
// target is NOT here yet — it needs the ring data, which is still on exp-shepherd's branch.
|
||||
// A tab whose panel cannot be drawn would be worse than a missing tab, so it arrives with
|
||||
// its data rather than ahead of it. `centered` is already a known panel in useBoard, so
|
||||
// adding the tab is one line once the rings land.
|
||||
const CENTRE_TABS = computed(() => [
|
||||
{ id: 'map', label: T('gospelInternet.centre.map', 'Map') },
|
||||
{ id: 'tree', label: T('gospelInternet.centre.tree', 'Language tree') },
|
||||
{ id: 'bounded', label: T('gospelInternet.centre.bounded', 'Bounded set') },
|
||||
{ id: 'map', label: T('gospelInternet.centre.map', 'Map') },
|
||||
])
|
||||
// the table under the centre: the people groups, or the gospel resources for their languages
|
||||
const tableTab = ref('groups')
|
||||
|
|
@ -247,6 +259,7 @@ useShadowStyles(
|
|||
<div v-show="!isNarrow || mobilePanel === 'map'" class="gb-map">
|
||||
<GiMap v-if="mapEver" v-show="board.centre.value === 'map'" />
|
||||
<GiTree v-if="treeEver" v-show="board.centre.value === 'tree'" />
|
||||
<GiBoundedSet v-if="boundedEver" v-show="board.centre.value === 'bounded'" />
|
||||
<div class="gb-seg gb-centre-tabs" role="group" :aria-label="T('gospelInternet.centre.aria', 'Show')">
|
||||
<button v-for="c in CENTRE_TABS" :key="c.id" type="button" :aria-pressed="board.centre.value === c.id ? 'true' : 'false'"
|
||||
:data-centre-tab="c.id" @click="board.setCentre(c.id)">{{ c.label }}</button>
|
||||
|
|
|
|||
66
plugin-designs/gospel-internet/src/boundedSet.js
Normal file
66
plugin-designs/gospel-internet/src/boundedSet.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* boundedSet.js — the bounded-set layout: reached INSIDE the circle, unreached OUTSIDE.
|
||||
*
|
||||
* The point of a bounded set is the BOUNDARY: a group is in or it is out, and the
|
||||
* picture should make that binary obvious rather than pretty. So position carries no
|
||||
* meaning here beyond the side of the line — there is no gradient, no distance, no
|
||||
* ranking. That is the whole claim the chart makes, and the centered-set target is
|
||||
* the chart that makes the opposite one.
|
||||
*
|
||||
* ⚠ REACHED-NESS IS NOT IN THE DASHBOARD ROWS. gospel-internet rows describe internet
|
||||
* and language access; `reached` lives in near-neighbors.json (EvangelicalLevel >= 2%,
|
||||
* the IMB definition). So the caller joins, and this module takes the answer as a
|
||||
* boolean it does not second-guess. A group whose reached-ness is UNKNOWN is its own
|
||||
* outcome — it is not quietly counted as unreached, because "we did not find it" and
|
||||
* "it is outside" are different facts and only one of them is about the group.
|
||||
*
|
||||
* Pure. No Vue, no DOM, no fetch — the layout is testable without mounting anything.
|
||||
*/
|
||||
|
||||
/** Deterministic 0..1 from a string, so a group sits in the same place on every render. */
|
||||
function hash01(s) {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < String(s).length; i++) {
|
||||
h ^= String(s).charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return ((h >>> 0) % 100000) / 100000
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay groups out around a circle of radius `r` centred at (cx, cy).
|
||||
*
|
||||
* Inside points are placed within 0.92r so they never graze the boundary; outside
|
||||
* points sit between 1.12r and `spread`. The gap on each side is deliberate — a dot
|
||||
* ON the line would be the one thing a bounded set cannot express.
|
||||
*/
|
||||
export function layoutBoundedSet(groups, { cx = 0, cy = 0, r = 100, spread = 220, key = (g) => g.id } = {}) {
|
||||
const inside = []
|
||||
const outside = []
|
||||
const unknown = []
|
||||
for (const g of groups || []) {
|
||||
const id = key(g)
|
||||
const a = hash01(id) * Math.PI * 2
|
||||
const t = hash01(id + '|r')
|
||||
if (g.reached === true) {
|
||||
const rad = Math.sqrt(t) * r * 0.92
|
||||
inside.push({ ...g, id, x: cx + Math.cos(a) * rad, y: cy + Math.sin(a) * rad, side: 'in' })
|
||||
} else if (g.reached === false) {
|
||||
const rad = r * 1.12 + t * (spread - r * 1.12)
|
||||
outside.push({ ...g, id, x: cx + Math.cos(a) * rad, y: cy + Math.sin(a) * rad, side: 'out' })
|
||||
} else {
|
||||
unknown.push({ ...g, id, side: 'unknown' })
|
||||
}
|
||||
}
|
||||
return { inside, outside, unknown, cx, cy, r }
|
||||
}
|
||||
|
||||
/** The counts a caption should quote. `unknown` is reported, never folded into `outside`. */
|
||||
export function boundedSetTally(layout) {
|
||||
return {
|
||||
inside: layout.inside.length,
|
||||
outside: layout.outside.length,
|
||||
unknown: layout.unknown.length,
|
||||
total: layout.inside.length + layout.outside.length + layout.unknown.length,
|
||||
}
|
||||
}
|
||||
107
plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
Normal file
107
plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<script setup>
|
||||
/**
|
||||
* GiBoundedSet.vue — the BOUNDED SET: reached groups inside the circle, unreached outside.
|
||||
*
|
||||
* The chart makes exactly one claim — which side of the line a group is on — so position
|
||||
* carries no other meaning: no gradient, no ranking, no distance. The centered-set target
|
||||
* is the chart that makes the opposite claim, and keeping them visually distinct is the
|
||||
* point of having both. Layout maths is in ../boundedSet.js, pure and testable without
|
||||
* mounting this.
|
||||
*
|
||||
* SVG RATHER THAN THE MAP ENGINE, which is the opposite of GiTree's call and for a reason
|
||||
* that does not contradict it. GiTree uses MapLibre because a language tree is thousands of
|
||||
* nodes that must stay readable through zoom, and SVG keeps every node in the page at every
|
||||
* zoom. This has no zoom, no labels and no camera: it is a static scatter of ~2,100 dots
|
||||
* under the default list. If it is ever pointed at the full 9,858-row table the honest fix
|
||||
* is to aggregate, not to reach for the GPU.
|
||||
*
|
||||
* ⚠ REACHED-NESS IS NOT IN THE DASHBOARD ROWS. They describe internet and language access.
|
||||
* `reached` lives in near-neighbors.json (EvangelicalLevel >= 2%, the IMB definition), so
|
||||
* this loads that table and joins on (name, iso3) — the pair measured at 994/1009. A group
|
||||
* that does not join is UNKNOWN, and is reported as unknown rather than being drawn outside
|
||||
* the circle: "we could not match it" and "it is unreached" are different facts, and only
|
||||
* one of them is about the group.
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { useBoardState } from '../useBoard.js'
|
||||
import { useGiT } from '../useGiT.js'
|
||||
import { layoutBoundedSet, boundedSetTally } from '../boundedSet.js'
|
||||
|
||||
const T = useGiT()
|
||||
const board = useBoardState()
|
||||
const rows = ref(null)
|
||||
const failed = ref('')
|
||||
|
||||
// The near-neighbours table, fetched once. Duplicated from upg-neighbor's nearData.js on
|
||||
// purpose rather than imported across plugins: a cross-plugin import couples two bundles
|
||||
// that ship separately. When the rings work is promoted to library/, this collapses into
|
||||
// the promoted loader and this comment goes with it.
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const cfg = board.profileConfig?.value || {}
|
||||
const url = cfg.nearDataUrl || `${resolveAssetsBase(cfg)}/tables/near-neighbors.json`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`${res.status} ${url}`)
|
||||
const d = await res.json()
|
||||
const ix = Object.fromEntries(d.cols.map((c, i) => [c, i]))
|
||||
const dec = (r, c) => (d.dict[c] ? d.dict[c][r[ix[c]]] : r[ix[c]])
|
||||
rows.value = d.rows.map((r) => ({
|
||||
name: dec(r, 'name'), iso3: dec(r, 'iso3'), reached: dec(r, 'reached') === 1,
|
||||
}))
|
||||
} catch (e) {
|
||||
// FAILS VISIBLY. A bounded set drawn from a table that did not load would be a circle
|
||||
// with everything outside it, which is a claim rather than an error.
|
||||
failed.value = String(e?.message || e)
|
||||
}
|
||||
})
|
||||
|
||||
const joined = computed(() => {
|
||||
if (!rows.value) return []
|
||||
const byKey = new Map(rows.value.map((r) => [`${String(r.name).toLowerCase()}|${String(r.iso3).toUpperCase()}`, r]))
|
||||
return (board.filtered?.value || []).map((g) => {
|
||||
const hit = byKey.get(`${String(g.people_group).toLowerCase()}|${String(g.country).toUpperCase()}`)
|
||||
return { id: g.slug || g.people_group, label: g.people_group, reached: hit ? hit.reached : undefined }
|
||||
})
|
||||
})
|
||||
|
||||
const layout = computed(() => layoutBoundedSet(joined.value, { r: 120, spread: 260 }))
|
||||
const tally = computed(() => boundedSetTally(layout.value))
|
||||
const fmt = (n) => (Number(n) || 0).toLocaleString()
|
||||
|
||||
useShadowStyles(
|
||||
'.gbs{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#f3f3f1}' +
|
||||
'.gbs svg{max-width:100%;max-height:100%}' +
|
||||
'.gbs-ring{fill:none;stroke:#3b463d;stroke-width:1.5}' +
|
||||
'.gbs-in{fill:#92b195}.gbs-out{fill:#6d766e;opacity:.65}' +
|
||||
'.gbs-cap{margin-block-start:8px;color:#3b463d;font-size:13px;text-align:center;max-width:46ch}' +
|
||||
'.gbs-warn{color:#6d766e;font-size:12px;margin-block-start:4px}',
|
||||
'gi-bounded-set',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gbs">
|
||||
<p v-if="failed" class="gbs-cap">
|
||||
{{ T('gospelInternet.bounded.failed', 'The reached/unreached table could not be loaded, so this chart is not drawn.') }}
|
||||
<span class="gbs-warn" dir="ltr">{{ failed }}</span>
|
||||
</p>
|
||||
<template v-else>
|
||||
<svg viewBox="-280 -280 560 560" role="img"
|
||||
:aria-label="T('gospelInternet.bounded.aria', 'Bounded set: reached groups inside the circle, unreached outside')">
|
||||
<circle class="gbs-ring" cx="0" cy="0" r="120" />
|
||||
<circle v-for="p in layout.outside" :key="p.id" class="gbs-out" :cx="p.x" :cy="p.y" r="2.2" />
|
||||
<circle v-for="p in layout.inside" :key="p.id" class="gbs-in" :cx="p.x" :cy="p.y" r="2.2" />
|
||||
</svg>
|
||||
<p class="gbs-cap">
|
||||
{{ T('gospelInternet.bounded.caption', 'Inside the circle: reached. Outside: unreached.') }}
|
||||
<b dir="ltr">{{ fmt(tally.inside) }} / {{ fmt(tally.outside) }}</b>
|
||||
</p>
|
||||
<p v-if="tally.unknown" class="gbs-warn">
|
||||
{{ T('gospelInternet.bounded.unknown', 'Not drawn, because their reached status did not match this table:') }}
|
||||
<b dir="ltr">{{ fmt(tally.unknown) }}</b>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -108,8 +108,25 @@ export function useBoard() {
|
|||
}
|
||||
return treeLoading
|
||||
}
|
||||
// The centre panels, in the order the Driver asked for them. This is a LIST rather than
|
||||
// a pair because the previous form — `mode === 'tree' ? 'tree' : 'map'` — silently
|
||||
// resolved every unknown id to the map. Adding a panel to the tab strip without adding
|
||||
// it here would have rendered the map under the new tab's label: no error, no empty
|
||||
// state, just the wrong panel wearing the right name. An unknown id is now a WARNING
|
||||
// and the default, which is how this codebase already treats an unknown people-group
|
||||
// list (peopleGroupList.js: fall back to the default and say so once).
|
||||
const CENTRE_PANELS = ['tree', 'bounded', 'centered', 'map']
|
||||
const warnedCentre = new Set()
|
||||
function setCentre(mode) {
|
||||
centre.value = mode === 'tree' ? 'tree' : 'map'
|
||||
if (CENTRE_PANELS.includes(mode)) {
|
||||
centre.value = mode
|
||||
} else {
|
||||
if (!warnedCentre.has(mode)) {
|
||||
warnedCentre.add(mode)
|
||||
console.warn(`[gospel-internet] unknown centre panel "${mode}", showing "${CENTRE_PANELS[0]}". Known: ${CENTRE_PANELS.join(', ')}`)
|
||||
}
|
||||
centre.value = CENTRE_PANELS[0]
|
||||
}
|
||||
if (centre.value === 'tree') loadTree().catch((e) => { error.value = String(e?.message || e) })
|
||||
}
|
||||
/**
|
||||
|
|
@ -227,7 +244,7 @@ export function useBoard() {
|
|||
const board = {
|
||||
bus, all, bySlug, filtered, sorted, meta, countries, readiness, loading, error, listId, listIsDefault,
|
||||
view, sort, openSlug, openRow, profileConfig,
|
||||
centre, tree, resources, treeNode, setCentre, setTreeNode, loadTree,
|
||||
centre, CENTRE_PANELS, tree, resources, treeNode, setCentre, setTreeNode, loadTree,
|
||||
load, setView, openGroup, closeGroup, readUrl, groupLink,
|
||||
}
|
||||
provide('giBoard', board)
|
||||
|
|
|
|||
Loading…
Reference in a new issue