Out: the grid's 'Open in Gospel Resources' opens the selected peoples in resources-map by its ?groups= contract (DOXA slugs: all 2,107 of the board's slugs are Gospel Resources' pin slugs; capped at 300). And every committed selection is dispatched as a bubbling, composed gospel-internet:selection event with codes (slugs, ISO 639-3, ISO 3166), the host captured in setup. In: ?groups= arrives selected, ?codes= and ?code= narrow by language, exactly as Gospel Resources reads them.
204 lines
10 KiB
JavaScript
204 lines
10 KiB
JavaScript
/**
|
|
* useBoard.js — the board's ONE shared state, provided to every panel (SPEC-DASHBOARD-KIT section 5.1,
|
|
* level 1: panels never talk to each other; they read and write one injected bus).
|
|
*
|
|
* Built FROM the kit: the bus is library/composables/useSelectionBus.js, unchanged. What is here is the
|
|
* thin glue the kit does not have yet — data loading, view presets and query-param URL state (kit D2,
|
|
* decided, not built) — written to the contract so each piece can be swapped for the kit's when it lands.
|
|
*
|
|
* Provides 'giBoard': { bus, all, filtered, meta, loading, error, view, sort, openSlug, listId, ... }.
|
|
*/
|
|
import { ref, shallowRef, computed, watch, inject, provide, markRaw } from 'vue'
|
|
import { createSelectionBus } from '@map/composables/useSelectionBus.js'
|
|
import { resolveAssetsBase, resolveShareUrl } from '@map/utils/embedBase.js'
|
|
import { applyPeopleGroupList, resolvePeopleGroupList, LIST_DEFAULT } from '@map/lists/peopleGroupList.js'
|
|
import * as M from './model.js'
|
|
|
|
export const EMBED_PATH = 'gospel-internet/board/index.html'
|
|
|
|
export function useBoard() {
|
|
const profileConfig = inject('profileConfig', ref(null))
|
|
const cfg = () => profileConfig.value || {}
|
|
|
|
const bus = createSelectionBus()
|
|
bus.configureHierarchy({ facetRail: M.FACET_RAIL })
|
|
|
|
const all = shallowRef([])
|
|
const bySlug = shallowRef(new Map())
|
|
const meta = shallowRef({})
|
|
const countries = shallowRef({}) // iso3 -> { messaging, top_messaging, in_app_feed, social, social_month }
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const listId = computed(() => resolvePeopleGroupList(profileConfig.value))
|
|
const listIsDefault = computed(() => listId.value === LIST_DEFAULT)
|
|
|
|
const view = ref('board')
|
|
const sort = ref({ key: 'population', dir: -1 })
|
|
const openSlug = ref('') // the group in the detail drawer ('' = closed)
|
|
// ── the centre panel: the geographic map or the language tree (loaded on first use) ───────────────
|
|
const centre = ref('map')
|
|
const tree = shallowRef(null) // indexTree(doc) + { meta }
|
|
const resources = shallowRef(null) // { meta, rows } — gospel links for the board's languages
|
|
const treeNode = ref('') // the tree node the board is narrowed to ('' = none)
|
|
let treeLoading = null
|
|
|
|
// Every panel's row set. The filterSet reads inside rowMatchesFilters are reactive, so this recomputes
|
|
// on any facet change; searchTerm is read explicitly for the same reason.
|
|
const filtered = computed(() => {
|
|
const term = bus.searchTerm.value
|
|
for (const k of Object.keys(bus.filterSet)) void bus.filterSet[k]
|
|
return M.filterRows(all.value, bus, M.FACET_ACC, term)
|
|
})
|
|
const sorted = computed(() => M.sortRows(filtered.value, sort.value))
|
|
const openRow = computed(() => (openSlug.value && bySlug.value.get(openSlug.value)) || null)
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const url = `${resolveAssetsBase(cfg())}/tables/gospel-internet.json`
|
|
const res = await fetch(url)
|
|
if (!res.ok) throw new Error(`gospel-internet.json HTTP ${res.status}`)
|
|
const doc = await res.json()
|
|
// The universal `list` prop: the rows of the page's people-group list, and none if it cannot be
|
|
// loaded (applyPeopleGroupList fails CLOSED — never DOXA's whole list under another list's name).
|
|
const rows = await applyPeopleGroupList(M.normalizeRows(doc), profileConfig.value)
|
|
meta.value = markRaw(doc.meta || {})
|
|
countries.value = markRaw(doc.countries || {})
|
|
all.value = markRaw(rows)
|
|
bySlug.value = markRaw(new Map(rows.map((r) => [r.slug, r])))
|
|
} catch (e) {
|
|
error.value = String(e?.message || e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadTree() {
|
|
if (tree.value) return tree.value
|
|
if (!treeLoading) {
|
|
const base = resolveAssetsBase(cfg())
|
|
treeLoading = Promise.all(['gospel-internet-tree.json', 'gospel-internet-resources.json'].map((f) =>
|
|
fetch(`${base}/tables/${f}`).then((r) => { if (!r.ok) throw new Error(`${f} HTTP ${r.status}`); return r.json() })))
|
|
.then(([t, res]) => {
|
|
tree.value = markRaw({ ...M.indexTree(t), meta: t.meta || {} })
|
|
resources.value = markRaw(res)
|
|
if (pendingTree) { setTreeNode(pendingTree); pendingTree = '' }
|
|
return tree.value
|
|
})
|
|
.catch((e) => { treeLoading = null; throw e })
|
|
}
|
|
return treeLoading
|
|
}
|
|
function setCentre(mode) {
|
|
centre.value = mode === 'tree' ? 'tree' : 'map'
|
|
if (centre.value === 'tree') loadTree().catch((e) => { error.value = String(e?.message || e) })
|
|
}
|
|
/**
|
|
* Narrow the board to one tree node: the rows whose language is at or below it. It goes through the
|
|
* EXISTING language facet as a multi-value filter (D1), so every panel, count and export follows by the
|
|
* bus's one predicate; the node id is remembered only to show and share which node it was.
|
|
*/
|
|
let treeSetKey = ''
|
|
function setTreeNode(id) {
|
|
const ix = tree.value
|
|
if (!id || !ix || !ix.byId.has(id)) { treeNode.value = ''; treeSetKey = ''; if (id === '') bus.setFilter('language', null); return }
|
|
const onBoard = new Set(all.value.map((r) => r.iso639_3))
|
|
const isos = [...ix.isos.get(id)].filter((i) => onBoard.has(i)).sort()
|
|
treeNode.value = id
|
|
// No language of this node is on the list: narrow to nothing rather than silently to everything.
|
|
const value = isos.length ? isos : ['(none)']
|
|
treeSetKey = value.join('|')
|
|
bus.setFilter('language', value)
|
|
}
|
|
// Another control changed or cleared the language facet: the node no longer describes the scope.
|
|
watch(() => { const v = bus.filterSet.language; return v == null ? '' : [].concat(v).join('|') }, (k) => {
|
|
if (treeNode.value && k !== treeSetKey) { treeNode.value = ''; treeSetKey = '' }
|
|
})
|
|
let pendingTree = ''
|
|
|
|
// ── views are PRESETS over the one bus ────────────────────────────────────────────────────────────
|
|
function setView(v, { keepFilters = false } = {}) {
|
|
const next = M.VIEWS.includes(v) ? v : 'board'
|
|
view.value = next
|
|
const preset = M.viewPreset(next)
|
|
if (!keepFilters) {
|
|
bus.clearFilters()
|
|
for (const [k, val] of Object.entries(preset.filters)) bus.setFilter(k, val)
|
|
}
|
|
sort.value = { ...preset.sort }
|
|
}
|
|
|
|
function openGroup(slug, { select = true } = {}) {
|
|
openSlug.value = slug || ''
|
|
// Opening a group is an explicit pick, so it SELECTS (and the selection camera moves once). Hover
|
|
// never does this — see useSelectionBus D3.
|
|
if (select && slug) bus.setSelection([slug])
|
|
}
|
|
function closeGroup() { openSlug.value = '' }
|
|
|
|
// ── URL state: ?view=&access=&band=&country=&province=&language=&group= (read always) ─────────────
|
|
// WRITTEN only when the host opts in (profile-config urlState:'query', set by our own leaf pages).
|
|
// A partner embedding the element on their page did not ask us to rewrite their address bar.
|
|
const writeUrl = computed(() => cfg().urlState === 'query')
|
|
function readUrl() {
|
|
if (typeof location === 'undefined') return
|
|
const q = M.parseQuery(location.search)
|
|
const hostView = cfg().view
|
|
setView(q.view !== 'board' ? q.view : (M.VIEWS.includes(hostView) ? hostView : 'board'))
|
|
// Explicit facets in the URL (or the host's profile-config `filters`) narrow the preset further.
|
|
const hostFilters = cfg().filters && typeof cfg().filters === 'object' ? cfg().filters : {}
|
|
for (const [k, v] of Object.entries({ ...hostFilters, ...q.filters })) if (k in M.FACET_ACC) bus.setFilter(k, v)
|
|
const group = q.group || cfg().group || ''
|
|
if (group) pendingGroup = group
|
|
const panel = q.panel === 'tree' || cfg().panel === 'tree' ? 'tree' : 'map'
|
|
if (q.tree) pendingTree = q.tree
|
|
if (q.groups && q.groups.length) pendingSelection = q.groups
|
|
setCentre(panel)
|
|
}
|
|
let pendingGroup = ''
|
|
let pendingSelection = null
|
|
watch(all, () => {
|
|
if (pendingGroup && bySlug.value.has(pendingGroup)) openGroup(pendingGroup)
|
|
pendingGroup = ''
|
|
// ?groups= arrives SELECTED (ringed, framed by the camera), the same peoples Gospel Resources would
|
|
// draw for that link; slugs not on this list are dropped, never guessed
|
|
if (pendingSelection) { bus.setSelection(pendingSelection.filter((s) => bySlug.value.has(s))); pendingSelection = null }
|
|
})
|
|
function syncUrl() {
|
|
if (!writeUrl.value || typeof history === 'undefined') return
|
|
const qs = M.buildQuery({ view: view.value, filterSet: bus.filterSet, group: openSlug.value, keep: location.search,
|
|
panel: centre.value, tree: treeNode.value })
|
|
const next = location.pathname + (qs ? '?' + qs : '') + location.hash
|
|
if (next !== location.pathname + location.search + location.hash) {
|
|
try { history.replaceState(history.state, '', next) } catch (_) { /* sandboxed */ }
|
|
}
|
|
}
|
|
watch([view, openSlug, centre, treeNode, () => JSON.stringify(bus.filterSet)], syncUrl)
|
|
|
|
/** A link to one group, for "Copy link to this group" — the board's own page, ?group=<slug>. */
|
|
function groupLink(slug) {
|
|
const base = resolveShareUrl({ embedPath: EMBED_PATH, profileConfig: cfg() })
|
|
try {
|
|
const u = new URL(base, typeof location !== 'undefined' ? location.href : 'https://example.invalid/')
|
|
u.searchParams.set('group', slug)
|
|
if (!listIsDefault.value) u.searchParams.set('list', listId.value)
|
|
return u.href
|
|
} catch (_) { return base + '?group=' + encodeURIComponent(slug) }
|
|
}
|
|
|
|
const board = {
|
|
bus, all, bySlug, filtered, sorted, meta, countries, loading, error, listId, listIsDefault,
|
|
view, sort, openSlug, openRow, profileConfig,
|
|
centre, tree, resources, treeNode, setCentre, setTreeNode, loadTree,
|
|
load, setView, openGroup, closeGroup, readUrl, groupLink,
|
|
}
|
|
provide('giBoard', board)
|
|
return board
|
|
}
|
|
|
|
export function useBoardState() {
|
|
const b = inject('giBoard', null)
|
|
if (!b) throw new Error('[gospel-internet] a panel was mounted outside the board (no giBoard provided)')
|
|
return b
|
|
}
|