diff --git a/plugin-designs/gospel-internet/board.vue b/plugin-designs/gospel-internet/board.vue index 022e4145b..0ef1379fc 100644 --- a/plugin-designs/gospel-internet/board.vue +++ b/plugin-designs/gospel-internet/board.vue @@ -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(
+
diff --git a/plugin-designs/gospel-internet/src/boundedSet.js b/plugin-designs/gospel-internet/src/boundedSet.js new file mode 100644 index 000000000..8cfddba28 --- /dev/null +++ b/plugin-designs/gospel-internet/src/boundedSet.js @@ -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, + } +} diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue new file mode 100644 index 000000000..c9d7cfdb4 --- /dev/null +++ b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue @@ -0,0 +1,107 @@ + + + diff --git a/plugin-designs/gospel-internet/src/useBoard.js b/plugin-designs/gospel-internet/src/useBoard.js index 285429ad2..1599b53d6 100644 --- a/plugin-designs/gospel-internet/src/useBoard.js +++ b/plugin-designs/gospel-internet/src/useBoard.js @@ -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)