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" /> +

{{ q2take }}

-
- +
+ +

{{ ntTake }}

diff --git a/plugin-designs/gospel-internet/src/components/GiGrid.vue b/plugin-designs/gospel-internet/src/components/GiGrid.vue index ab09d49f..63e5ea4c 100644 --- a/plugin-designs/gospel-internet/src/components/GiGrid.vue +++ b/plugin-designs/gospel-internet/src/components/GiGrid.vue @@ -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…')" /> + {{ T('gospelInternet.grid.openInGospel', 'Open in Gospel Resources') }} {{ status }}
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 '&amp;'. 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": [ {