diff --git a/plugin-designs/gospel-internet/board.vue b/plugin-designs/gospel-internet/board.vue
index 48e0ad61..a37416a7 100644
--- a/plugin-designs/gospel-internet/board.vue
+++ b/plugin-designs/gospel-internet/board.vue
@@ -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}' +
diff --git a/plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs b/plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs
index c213da8e..1f30a0c6 100644
--- a/plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs
+++ b/plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs
@@ -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(
diff --git a/plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs b/plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs
index 4e1ca689..4dbe283f 100644
--- a/plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs
+++ b/plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs
@@ -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(//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*[^<]+' + (POISON ? 'x' : '220')).test(en.html), 'en: a combination shows its live count (digital-now = 220)')
void fam
const ar = await render('ar')
diff --git a/plugin-designs/gospel-internet/src/components/GiCharts.vue b/plugin-designs/gospel-internet/src/components/GiCharts.vue
index 699db9aa..8c286704 100644
--- a/plugin-designs/gospel-internet/src/components/GiCharts.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCharts.vue
@@ -1,69 +1,98 @@
@@ -74,32 +103,34 @@ useShadowStyles(
-
-
+
+
+
{{ q1take }}
-
-
+
+
+
{{ gapTake }}
-
-
+
+ :empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" @bar-click="onBar" />
+
diff --git a/plugin-designs/gospel-internet/src/model.js b/plugin-designs/gospel-internet/src/model.js
index c872f80f..965f59ad 100644
--- a/plugin-designs/gospel-internet/src/model.js
+++ b/plugin-designs/gospel-internet/src/model.js
@@ -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 '&'. Every link in this
diff --git a/plugin-designs/gospel-internet/src/useBoard.js b/plugin-designs/gospel-internet/src/useBoard.js
index 09194369..96f4d136 100644
--- a/plugin-designs/gospel-internet/src/useBoard.js
+++ b/plugin-designs/gospel-internet/src/useBoard.js
@@ -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
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index 33404e99..0a8afaca 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -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": "",
- "script": "\n\n\n\n"
+ "script": "\n\n\n\n"
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index 8aef6dc2..d9b87454 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n"
+ "script": "\n\n\n\n"
},
"instances": [
{