Update from source 69a1bd20
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-27 08:53:41 +00:00
parent 65c1339fb2
commit d05d9dd55d
17 changed files with 224 additions and 110 deletions

View file

@ -21,7 +21,7 @@
* internet figures are the country's (or state's); "found in the sources checked", never "reached"; no
* engagement, no one-works-here. Sources are named from the table's own meta.
*/
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, getCurrentInstance } from 'vue'
import { useDashboardLayout } from '@map/composables/useDashboardLayout.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { listLabel } from '@map/lists/peopleGroupList.js'
@ -39,6 +39,29 @@ import GiCombos from './src/components/GiCombos.vue'
const T = useGiT()
const board = useBoard()
const { bus } = board
// ── INTEROP OUT: every committed selection leaves as a DOM event another element or the host page can
// take (SPEC-DASHBOARD-KIT 5.3, level 2): bubbling + composed, so it crosses the shadow boundary; CODES,
// never labels (slugs, ISO 639-3, ISO 3166 alpha-3); debounced so a range drag sends one event. The host
// is found through the instance captured HERE, in setup: getCurrentInstance() in a watcher is null, and
// that is exactly how Simple Research's map -> grid event silently never fired.
const selfInstance = getCurrentInstance()
let selTimer = null
watch(() => bus.selectionVersion.value, () => {
if (selTimer) clearTimeout(selTimer)
selTimer = setTimeout(() => {
selTimer = null
const host = selfInstance?.proxy?.$el?.getRootNode?.()?.host
if (!host) return
const slugs = [...bus.selectionSet]
const rows = slugs.map((s) => board.bySlug.value.get(s)).filter(Boolean)
host.dispatchEvent(new CustomEvent('gospel-internet:selection', { bubbles: true, composed: true, detail: {
slugs, languages: [...new Set(rows.map((r) => r.iso639_3))], countries: [...new Set(rows.map((r) => r.iso3))],
list: board.listId.value,
} }))
}, 150)
})
onBeforeUnmount(() => { if (selTimer) clearTimeout(selTimer) })
// The URL / host preset (view, facets, centre panel, group) is applied BEFORE the first render, so the
// board opens already in its state: no flash of the default view, and the first render mounts the panel
// the preset asks for (the language tree included) where a headless mount check can see it.
@ -62,7 +85,7 @@ onBeforeUnmount(() => { try { ro && ro.disconnect() } catch (_) {} })
// The kit's layout engine, under THIS board's key (landing 2's namespacing), grid mode.
const { startFacetResize, startRailResize, startTableResize, onResizeKeydown, facetW, railW, tableH } = useDashboardLayout({
shellRef: shell, bus, isNarrow, storageKey: 'gi-board-layout-v1',
defaults: { facetW: 264, railW: 300, tableH: '38%' },
defaults: { facetW: 264, railW: 360, tableH: '38%' },
})
// ── the headline: the board's question answered in one line, for the list in view ───────────────────
@ -124,9 +147,11 @@ const sourcesLine = computed(() => (board.meta.value.sources || []).join(' · ')
useShadowStyles(
':host{display:block}' +
'.gb{--gb-bg:#f3f3f1;position:relative;box-sizing:border-box;width:100%;height:100%;min-height:640px;display:grid;background:var(--gb-bg);color:#3b463d;' +
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,300px);' +
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,360px);' +
'grid-template-rows:auto auto auto minmax(0,1fr) var(--table-h,38%);' +
'grid-template-areas:"top top top" "combos combos combos" "note note note" "facets map rail" "facets grid grid";' +
// The charts rail runs the full height beside the map AND the table: sized to the map row alone it was
// ~300px and clipped its four charts (rega, the agent: "the charts don't look good").
'grid-template-areas:"top top top" "combos combos combos" "note note note" "facets map rail" "facets grid rail";' +
'font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;overflow:hidden}' +
'.gb-top{grid-area:top;display:flex;align-items:center;flex-wrap:wrap;gap:10px 16px;padding:10px 14px;background:#fff;border-bottom:1px solid #d1d4cd}' +
'.gb-title{margin:0;font-size:17px;font-weight:700;letter-spacing:.005em}' +

View file

@ -82,6 +82,13 @@ const back = M.buildQuery({ view: 'zero', filterSet: { band: ['yes', 'partial'],
ok(back.includes('lang=ar') && back.includes('list=700') && back.includes('band=yes%7Cpartial') && back.includes('view=zero'),
`query builds and keeps lang/list: ${back}`)
ok(M.parseQuery('?view=nonsense').view === 'board', 'an unknown view falls back to board')
// embed-contract parity with Gospel Resources: ?groups= (peoples by slug) and ?codes= / ?code= (languages)
const pg = M.parseQuery('?groups=abai-sungai,hadrami-arabs&codes=abf,ayh')
ok(pg.groups.length === 2 && Array.isArray(pg.filters.language) && pg.filters.language.join('|') === 'abf|ayh', 'parses ?groups= (slugs) and ?codes= (languages), comma-separated as Gospel Resources writes them')
ok(M.parseQuery('?code=ara').filters.language === 'ara' && M.parseQuery('?codes=ARAB,xx').filters.language === undefined, '?code= alone is a language; malformed codes are refused, never guessed')
const link = M.gospelResourcesLink('https://x.test/dx-maps/gospel-resources/resources-map/index.html', ['a', 'b'], { list: '700', lang: 'ar' })
ok(link.includes('groups=a%2Cb') && link.includes('list=700') && link.includes('lang=ar'), `out-link to Gospel Resources carries groups, list and lang: ${link.split('?')[1]}`)
ok(new URLSearchParams(M.gospelResourcesLink('u', Array.from({ length: 500 }, (_, i) => 's' + i)).split('?')[1]).get('groups').split(',').length === M.GROUPS_LINK_CAP, 'the out-link is capped at GROUPS_LINK_CAP slugs')
// 7. CSV
const csv = M.buildCsv(

View file

@ -61,7 +61,7 @@ try {
b.tree.value = markRaw({ ...M.indexTree(treeDoc), meta: treeDoc.meta })
b.resources.value = markRaw(res)
b.loading.value = false
if (open) b.openSlug.value = open.slug
if (open) { b.openSlug.value = open.slug; b.bus.setSelection([open.slug]) }
// narrow to the largest family through the tree, as a click would
const fam = b.tree.value.nodes.filter((n) => !n.p).sort((x, y) => b.tree.value.isos.get(y.id).size - b.tree.value.isos.get(x.id).size)[0]
b.setTreeNode(fam.id)
@ -89,6 +89,10 @@ try {
['GiTree', /data-tree-legend[\s\S]*data-tree-poster/], ['GiResources', /data-resources-total/], ['GiMap', /data-region="map"/]]) ok(re.test(en.html), `en: ${name} rendered its region`)
ok(!!open && en.html.includes(open.people_group), `en: the drawer shows the opened group (${open ? open.people_group : 'no row'})`)
ok(/data-notice="approximate-pin"/.test(en.html), 'en: the drawer carries the approximate-pin notice')
// interop out: the selection opens in Gospel Resources by its ?groups= contract, carrying the slug
const gl = (en.html.match(/href="([^"]*)"[^>]*data-open-gospel/) || [])[1] || ''
ok(!!open && gl.includes('gospel-resources/resources-map/index.html') && decodeURIComponent(gl).includes('groups=' + open.slug) && !POISON,
`en: the selection links out to Gospel Resources with ?groups= (${gl.split('?')[1] || 'no link'})`)
ok(/data-tree-card/.test(en.html), 'en: the tree card shows the node the board is narrowed to')
ok(/href="https?:\/\/[^"]+"/.test(en.html.slice(en.html.indexOf('data-region="resources"'))), 'en: the resources tab lists real links')
ok(/\b\d[\d,]* of [\d,]+ people groups\b/.test(en.html), 'en: the grid prints its scope count')
@ -97,6 +101,12 @@ try {
const fam = treeDoc.nodes.filter((n) => !n.p).sort((x, y) => 0)[0]
const famNode = (() => { const ix = M.indexTree(treeDoc); return ix.nodes.filter((n) => !n.p).sort((x, y) => ix.isos.get(y.id).size - ix.isos.get(x.id).size)[0] })()
ok(/data-chip="language"/.test(en.html) && en.html.includes(famNode.n), `en: the kit chip names the tree node (${famNode.n})`)
// the charts answer their question in words (the harness narrows to a family first, so assert the
// takeaways' FORM and that the gap charts name real groups/languages, not the roster's totals)
const plain = en.html.replace(/<!--[\s\S]*?-->/g, '')
ok(/\d[\d,]* of the \d[\d,]* with nothing live in countries/.test(plain) && !POISON, 'en: chart 3 states its takeaway')
ok(/data-chart="gaps"[\s\S]*?These \d+ alone are [\d,]+ people/.test(plain), 'en: the gap chart names its groups and totals them')
ok(/data-chart="ntNeed"[\s\S]*?languages here have no New Testament found/.test(plain), 'en: the New Testament chart states its takeaway')
ok(new RegExp('data-combo="digital-now"[^>]*>\\s*<span>[^<]+</span><span class="gic2-n">' + (POISON ? 'x' : '220')).test(en.html), 'en: a combination shows its live count (digital-now = 220)')
void fam
const ar = await render('ar')

View file

@ -1,69 +1,98 @@
<script setup>
/**
* GiCharts.vue — the right rail: the "bridge through the internet" picture in two charts, over the rows
* in scope (they follow the facets, like every panel).
* 1. In their own language x country internet band — stacked bars, groups or people.
* 2. Nothing in their own language, by band — the zero-access front door.
* The library's BarChart draws both (hand-rolled SVG, no chart CDN: a third-party request from a
* persecuted reader's device is a safety leak). A bar is a filter: clicking one toggles that band.
* GiCharts.vue — the right rail: FOUR charts, each answering one plain question about the groups shown,
* chosen to show a newcomer the GAPS they would not otherwise see (the agent, 2026-09-27: "a reader can't
* tell what they're saying"; "showcase the gaps ... the biggest, most helpful, clearest data"). Each
* carries its question as the title, says what one bar is and what it counts, and ends in a one-line
* takeaway computed from the same rows.
* 1. What do they have in their own language? one bar per level of access
* 2. The largest people groups with nothing the 8 biggest gaps, by name (click opens one)
* 3. Could the internet carry it to those with nothing? by how much of their country is online
* 4. Which languages most need the New Testament? languages with none found, by people who speak them
* Dropped: a stacked access x band chart (five colours on four bars, no readable answer), a social
* platform chart and a messaging-app chart (channels, not gaps: the facets and drawer carry them).
* They follow the facets like every panel; a bar is a filter (click to narrow the board to it).
*/
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import BarChart from '@map/components/charts/BarChart.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { accessByBand, countriesBy, ACCESS_DETAILS, BANDS, DETAIL_FILL, label } from '../model.js'
import { ACCESS_DETAILS, BANDS, DETAIL_FILL, BAND_COLOR, TREE_COLOR, label } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const measure = ref('groups')
const fmt = (v) => (Number(v) || 0).toLocaleString()
// The 'none' segment is drawn in the rim colour: a white segment on a white chart would be invisible.
const SEG_COLOR = { ...DETAIL_FILL, none: '#7d3b30' }
const stack = computed(() => {
const m = accessByBand(board.filtered.value, measure.value)
return BANDS.map((b) => {
const segs = ACCESS_DETAILS.map((d) => ({ label: label(T, 'detail', d), value: m[b][d], color: SEG_COLOR[d] }))
return { label: label(T, 'band', b), value: segs.reduce((s, x) => s + x.value, 0), segments: segs, meta: { band: b } }
})
})
const legend = computed(() => ACCESS_DETAILS.map((d) => ({ label: label(T, 'detail', d), color: SEG_COLOR[d] })))
const zero = computed(() => {
const rows = board.filtered.value.filter((r) => r.access_detail === 'none')
return BANDS.map((b) => {
const inBand = rows.filter((r) => r.internet_band === b)
const people = inBand.reduce((s, r) => s + (Number(r.population) || 0), 0)
return { label: label(T, 'band', b), value: measure.value === 'people' ? people : inBand.length,
note: measure.value === 'people' ? T('gospelInternet.charts.groupsN', '{n} groups', { n: fmt(inBand.length) }) : T('gospelInternet.charts.peopleN', '{n} people', { n: fmt(people) }),
color: '#7d3b30', meta: { band: b } }
})
})
// Charts 3 and 4: which apps would carry a resource into the countries in scope. Counted by COUNTRY,
// and each source's own caveat is printed under its chart (verbatim from the table's meta).
const notInFeed = () => T('gospelInternet.charts.notMeasured', 'Not measured')
const appSeries = computed(() => countriesBy(board.filtered.value, board.countries.value, (c) => c && c.top_messaging)
.map((e) => ({ label: e.value || notInFeed(), value: e.n, color: e.value ? '#3b463d' : '#9aa2ad', meta: { app: e.value } })))
const socialSeries = computed(() => countriesBy(board.filtered.value, board.countries.value, (c) => c && c.social && c.social[0] && c.social[0].platform)
.map((e) => ({ label: e.value || notInFeed(), value: e.n, color: e.value ? '#92b195' : '#9aa2ad' })))
const pmeta = computed(() => board.meta.value.platforms || {})
const unitCountries = computed(() => T('gospelInternet.charts.unitCountries', 'countries'))
function onApp(row) { const a = row && row.meta && row.meta.app; if (a) bus.toggleFilterValue('messaging', a) }
const platformsFirst = computed(() => board.view.value === 'platforms')
const count = (rs) => measure.value === 'people' ? rs.reduce((s, r) => s + (Number(r.population) || 0), 0) : rs.length
const unit = computed(() => measure.value === 'people' ? T('gospelInternet.charts.unitPeople', 'people') : T('gospelInternet.charts.unitGroups', 'people groups'))
function onBar(row) { const b = row && row.meta && row.meta.band; if (b) bus.toggleFilterValue('band', b) }
const rows = computed(() => board.filtered.value)
// 1 ─ what they have in their own language
const SEG_COLOR = { ...DETAIL_FILL, none: '#7d3b30' } // a white bar would vanish on white
const q1 = computed(() => ACCESS_DETAILS.map((d) => {
const rs = rows.value.filter((r) => r.access_detail === d)
return { label: label(T, 'detail', d), value: count(rs), color: SEG_COLOR[d], meta: { key: 'access_detail', value: d } }
}))
const q1take = computed(() => {
const none = rows.value.filter((r) => r.access_detail === 'none').length
return T('gospelInternet.charts.q1take', '{n} of {total} people groups shown have nothing found in their own language.', { n: fmt(none), total: fmt(rows.value.length) })
})
// 2 ─ could the internet carry it to those with nothing?
const zero = computed(() => rows.value.filter((r) => r.access_detail === 'none'))
const q2 = computed(() => BANDS.map((b) => {
const rs = zero.value.filter((r) => r.internet_band === b)
return { label: label(T, 'band', b), value: count(rs), color: BAND_COLOR[b], meta: { key: 'band', value: b } }
}))
const q2take = computed(() => {
const online = zero.value.filter((r) => r.internet_band === 'yes').length
return T('gospelInternet.charts.q2take', '{online} of the {n} with nothing live in countries where 60% or more use the internet.', { online: fmt(online), n: fmt(zero.value.length) })
})
// 2 ─ the largest people groups with nothing in their own language: the biggest gaps, by name
const TOP = 8
const gaps = computed(() => [...zero.value].sort((a, b) => (Number(b.population) || 0) - (Number(a.population) || 0)).slice(0, TOP))
const gapSeries = computed(() => gaps.value.map((r) => ({ label: `${r.people_group} (${r.iso3})`, value: Number(r.population) || 0, color: SEG_COLOR.none, meta: { open: r.slug } })))
const gapTake = computed(() => T('gospelInternet.charts.gapTake', 'These {k} alone are {people} people.', { k: fmt(gaps.value.length), people: fmt(gaps.value.reduce((s, r) => s + (Number(r.population) || 0), 0)) }))
// 4 ─ which languages most need the New Testament? (status per language comes from the language tree)
onMounted(() => { board.loadTree().catch(() => {}) })
const ntNeed = computed(() => {
const ix = board.tree.value
if (!ix) return []
const by = new Map()
for (const r of rows.value) {
const node = ix.byId.get(ix.nodeOfIso.get(r.iso639_3))
if (!node || node.st === 'g') continue
const e = by.get(r.iso639_3) || { iso: r.iso639_3, name: r.language, st: node.st, people: 0 }
e.people += Number(r.population) || 0
by.set(r.iso639_3, e)
}
return [...by.values()].sort((a, b) => b.people - a.people)
})
const ntSeries = computed(() => ntNeed.value.slice(0, TOP).map((e) => ({ label: `${e.name} (${e.iso})`, value: e.people, color: TREE_COLOR[e.st], meta: { key: 'language', value: e.iso } })))
const ntTake = computed(() => T('gospelInternet.charts.ntTake', '{n} languages here have no New Testament found, spoken by {people} people.', { n: fmt(ntNeed.value.length), people: fmt(ntNeed.value.reduce((s, e) => s + e.people, 0)) }))
function onBar(row) {
const m = row && row.meta
if (!m) return
if (m.open) board.openGroup(m.open)
else bus.toggleFilterValue(m.key, m.value)
}
useShadowStyles(
'.gic{display:flex;flex-direction:column;height:100%;overflow-y:auto;box-sizing:border-box;padding:10px 12px 16px;background:#fff;color:#3b463d;font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;scrollbar-width:thin}' +
'.gic-seg{display:inline-flex;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden;margin-bottom:8px}' +
'.gic{display:flex;flex-direction:column;gap:4px;height:100%;overflow-y:auto;box-sizing:border-box;padding:10px 12px 16px;background:#fff;color:#3b463d;font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;scrollbar-width:thin}' +
// A flex column's children SHRINK by default: inside this scroll box they were squeezed instead of
// scrolled, so the measure toggle collapsed to a 2px line and a caveat card was cut in half (rega).
'.gic > *{flex-shrink:0}' +
'.gic-seg{align-self:flex-start;display:inline-flex;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden;margin-bottom:4px}' +
'.gic-seg button{font:inherit;font-size:12px;padding:4px 12px;min-height:26px;border:0;background:#fff;color:#3b463d;cursor:pointer}' +
'.gic-seg button[aria-pressed="true"]{background:#3b463d;color:#fff}' +
'.gic-seg button:focus-visible{outline:2px solid #92b195;outline-offset:-2px}' +
'.gic-seg{align-self:flex-start;order:-1}' +
'.gic-card{border-bottom:1px solid #e6e8e3;padding:6px 0 12px;margin-bottom:6px}',
'.gic-card{border-bottom:1px solid #e6e8e3;padding:4px 0 12px}' +
'.gic-take{margin:4px 0 0;font-size:12.5px;font-weight:600;color:#3b463d}',
'gi-charts',
)
</script>
@ -74,32 +103,34 @@ useShadowStyles(
<button type="button" :aria-pressed="measure === 'groups'" @click="measure = 'groups'">{{ T('gospelInternet.charts.groups', 'Groups') }}</button>
<button type="button" :aria-pressed="measure === 'people'" @click="measure = 'people'">{{ T('gospelInternet.charts.people', 'People') }}</button>
</div>
<div class="gic-card" :style="{ order: platformsFirst ? 0 : 3 }">
<BarChart :series="appSeries" :unit="unitCountries" :bar-height="18" :gap="6"
:title="T('gospelInternet.charts.messagingTop', 'Top messaging app, by country in scope')"
:caveats="[pmeta.ios_caveat, pmeta.ranking_caveat, pmeta.classification_caveat].filter(Boolean)"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onApp" />
<div class="gic-card" data-chart="q1">
<BarChart :series="q1" :unit="unit" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.q1title', 'What do they have in their own language?')"
:subtitle="T('gospelInternet.charts.q1sub', 'One bar per level of access, for the people groups shown.')"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" @bar-click="onBar" />
<p class="gic-take">{{ q1take }}</p>
</div>
<div class="gic-card" :style="{ order: platformsFirst ? 0 : 4 }">
<BarChart :series="socialSeries" :unit="unitCountries" :bar-height="18" :gap="6"
:title="T('gospelInternet.charts.socialTop', 'Top social platform, by country in scope')"
:caveats="[pmeta.social_caveat].filter(Boolean)"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" />
<div class="gic-card" data-chart="gaps">
<BarChart :series="gapSeries" :unit="T('gospelInternet.charts.unitPeople', 'people')" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.gapTitle', 'The largest people groups with nothing in their own language')"
:subtitle="T('gospelInternet.charts.gapSub', 'The 8 biggest of those shown, by population. Click one to open it.')"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" @bar-click="onBar" />
<p v-if="gaps.length" class="gic-take">{{ gapTake }}</p>
</div>
<div class="gic-card" style="order:1">
<BarChart :series="stack" :legend="legend" :unit="unit" :bar-height="20" :gap="8"
:title="T('gospelInternet.charts.accessByBand', 'Own-language access, by country internet use')"
<div class="gic-card" data-chart="q2">
<BarChart :series="q2" :unit="unit" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.q2title', 'Could the internet carry it to those with nothing?')"
:subtitle="T('gospelInternet.charts.q2sub', 'People groups with nothing in their own language, by how much of their country is online.')"
:caveats="[T('gospelInternet.charts.bandCaveat', 'Internet use is the country\'s figure (World Bank), never the group\'s.')]"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onBar" />
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" @bar-click="onBar" />
<p v-if="zero.length" class="gic-take">{{ q2take }}</p>
</div>
<div class="gic-card" style="order:2">
<BarChart :series="zero" :unit="unit" :bar-height="20" :gap="8" color="#7d3b30"
:title="T('gospelInternet.charts.zeroByBand', 'Nothing in their own language, by country internet use')"
:caveats="[T('gospelInternet.charts.zeroCaveat', 'Every one of these has a way in: a bridge language, a candidate or a Deaf bridge.')]"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onBar" />
<div class="gic-card" data-chart="ntNeed">
<BarChart :series="ntSeries" :unit="T('gospelInternet.charts.unitPeople', 'people')" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.ntTitle', 'Which languages most need the New Testament?')"
:subtitle="T('gospelInternet.charts.ntSub', 'Languages of the groups shown with no New Testament found, by how many people speak them. Red: nothing at all; yellow: something, but no New Testament.')"
:empty-text="T('grid.loading', 'Loading…')" @bar-click="onBar" />
<p v-if="ntNeed.length" class="gic-take">{{ ntTake }}</p>
</div>
</aside>
</template>

View file

@ -18,7 +18,8 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { buildColumns } from '../columns.js'
import { buildCsv, exportScope, safeHref, DETAIL_FILL, DETAIL_RIM, BAND_COLOR } from '../model.js'
import { buildCsv, exportScope, safeHref, gospelResourcesLink, GROUPS_LINK_CAP, DETAIL_FILL, DETAIL_RIM, BAND_COLOR } from '../model.js'
import { resolveShareUrl } from '@map/utils/embedBase.js'
const T = useGiT()
const board = useBoardState()
@ -108,6 +109,15 @@ function exportCsv() {
status.value = T('gospelInternet.grid.exported', '{n} rows exported (copied and downloaded).', { n: n.toLocaleString() })
setTimeout(() => { status.value = '' }, 4000)
}
// INTEROP OUT, as a link: the selected peoples, opened in Gospel Resources' map (its ?groups= contract).
const gospelLink = computed(() => {
void bus.selectionVersion.value
if (!bus.selectionSet.size) return ''
const cfg = board.profileConfig.value || {}
const base = resolveShareUrl({ embedPath: 'gospel-resources/resources-map/index.html', profileConfig: { ...cfg, shareUrl: '' } })
return gospelResourcesLink(base, bus.selectionSet, { list: board.listIsDefault.value ? '' : board.listId.value, lang: cfg.lang || cfg.locale || '' })
})
const gospelCapped = computed(() => { void bus.selectionVersion.value; return bus.selectionSet.size > GROUPS_LINK_CAP })
const exportLabel = computed(() => selCount.value ? T('dashboard.export_selection', 'Export this selection') : T('dashboard.export_csv', 'Export CSV'))
const fmt = (n) => (Number(n) || 0).toLocaleString()
@ -118,6 +128,7 @@ useShadowStyles(
'.gig-search{flex:1 1 180px;max-width:320px;border:1px solid #d1d4cd;border-radius:6px;padding:5px 9px;font:inherit;font-size:12.5px;background:#fff;color:#3b463d}' +
'.gig-btn{border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;padding:4px 10px;min-height:28px;font:inherit;font-size:12.5px;cursor:pointer}' +
'.gig-btn:hover{border-color:#92b195}' +
'a.gig-btn{text-decoration:none;display:inline-flex;align-items:center}' +
'.gig-btn.primary{background:#3b463d;border-color:#3b463d;color:#fff}' +
'.gig-btn:focus-visible,.gig-th button:focus-visible,.gig-tr:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
'.gig-status{font-size:12px;color:#4f6b52}' +
@ -157,6 +168,8 @@ useShadowStyles(
:placeholder="T('grid.search_people_or_countries', 'Search people groups or countries…')"
:aria-label="T('grid.search_people_or_countries', 'Search people groups or countries…')" />
<button v-if="selCount" type="button" class="gig-btn" @click="bus.clearSelection()">{{ T('buttons.clearSelection', 'Clear selection') }} ({{ fmt(selCount) }})</button>
<a v-if="gospelLink" class="gig-btn" :href="gospelLink" target="_blank" rel="noopener" data-open-gospel
:title="gospelCapped ? T('gospelInternet.grid.openCapped', 'Opens the first {n} selected.', { n: fmt(GROUPS_LINK_CAP) }) : ''">{{ T('gospelInternet.grid.openInGospel', 'Open in Gospel Resources') }}</a>
<span class="gig-status" role="status">{{ status }}</span>
<span style="flex:1"></span>
<div class="gig-picker">

View file

@ -94,11 +94,18 @@ export function parseQuery(search) {
if (vals.length) filters[key] = vals.length === 1 ? vals[0] : vals
}
const view = VIEWS.includes(q.get('view')) ? q.get('view') : 'board'
// EMBED-CONTRACT PARITY with Gospel Resources (resources-map.vue:341/354): ?codes= (and ?code=) name
// LANGUAGES by ISO 639-3, ?groups= names PEOPLES by DOXA slug. Comma-separated there, so accepted with
// ',' as well as '|' here (neither can occur inside a slug or a code).
const list = (name) => (q.get(name) || '').split(/[,|]/).map((s) => s.trim().toLowerCase()).filter(Boolean)
const codes = [...list('codes'), ...list('code')].filter((c) => /^[a-z]{3}$/.test(c))
if (codes.length && !filters.language) filters.language = codes.length === 1 ? codes[0] : codes
const groups = list('groups').filter((s) => /^[a-z0-9-]+$/.test(s))
const panel = q.get('panel') === 'tree' ? 'tree' : 'map'
const tree = /^[a-z0-9-]{3,40}$/.test(q.get('tree') || '') ? q.get('tree') : ''
// A tree node narrows by LANGUAGE (its ISO codes); the URL carries the node, not the code list.
if (tree) delete filters.language
return { view, filters, group: q.get('group') || '', panel, tree }
return { view, filters, group: q.get('group') || '', panel, tree, groups }
}
/** The inverse: filterSet + view + group -> a query string (no leading '?'), stable key order. */
@ -300,6 +307,22 @@ export function exportScope(rows, filtered, selectionSet) {
return { scope: filtered.length === rows.length ? 'all' : 'filter', rows: filtered }
}
/**
* Where a selection goes next: Gospel Resources' map, narrowed to exactly these peoples (its ?groups=
* contract, by DOXA slug). A URL is the interop link that works across bundles, pages and partner sites
* alike. Capped: a link past a few hundred slugs outgrows what browsers and servers accept, so the first
* GROUPS_LINK_CAP go and the caller says so. `base` is the tree's embed base (resolveShareUrl).
*/
export const GROUPS_LINK_CAP = 300
export function gospelResourcesLink(base, slugs, { list = '', lang = '' } = {}) {
const pick = [...slugs].slice(0, GROUPS_LINK_CAP)
const q = new URLSearchParams()
if (pick.length) q.set('groups', pick.join(','))
if (list) q.set('list', list)
if (lang && lang !== 'en') q.set('lang', lang)
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`
}
/** A safe outbound URL: http(s) only (a javascript: or data: link in a table cell is never followed).
* Not library/utils/html.js safeUrl: that one HTML-ESCAPES its result for string-built popups, and a
* Vue :href binding escapes again, so '&' would reach the browser as '&amp;amp;'. Every link in this

View file

@ -153,12 +153,17 @@ export function useBoard() {
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

View file

@ -604,7 +604,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ",
"integrity": "sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc",
"elements": [
"gospel-internet-board"
],
@ -623,7 +623,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
<script src="../gospel-internet.js" integrity="sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -604,7 +604,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ",
"integrity": "sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc",
"elements": [
"gospel-internet-board"
],
@ -623,7 +623,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-3ZHm1Lbxu8tMP78Os4rOshTL1IQyvVwN2mtzG3PyZWqDngQpBYbkvhjWgYe459Dc\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{