Gospel × Internet: a ring legend that decodes the centered-set target, every ring named, counted and explained

This commit is contained in:
Doxa Maps 2026-09-28 16:09:15 +00:00
parent 0b81a1636d
commit f49c81cec6
2 changed files with 231 additions and 0 deletions

View file

@ -0,0 +1,175 @@
<script setup>
/**
* GiRingLegend.vue — the DECODER for the centered-set target: what every ring means, how many
* groups sit on it, and one real example, so nobody has to guess.
*
* Laid out as the grid the rings actually are (ringInfo.js): rows = how much language a group
* shares with its closest reached neighbour (dialect / language / family), columns = how close
* the culture is (same / near / different), ring 10 (nearby only) below, and the cross (reached)
* above. Each cell is a button with a small target glyph that highlights ITS ring.
*
* EVERY CELL RENDERS, EVEN AT ZERO. Presence is by identity, never by count: under a list filter
* several rings are legitimately empty, and a decoder that hid them would stop being a decoder.
* THE FOOTER ACCOUNTS FOR EVERY GROUP the rings cannot show: the centre (reached), no relation
* found (unplaced) and not in the table at all (counted by the chart at its join, passed in).
*
* Props: groups the chart's joined groups { ring, label, neighborId } (so counts match the chart)
* unmatched groups the chart could not join at all
* activeRing the ring the chart is highlighting (1-10), or null
* Emits: select(ring | null), fullscreen
*/
import { ref, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useGiT } from '../useGiT.js'
import { BANDS, CULTURES, CENTRE_COLOR, ringInfo, ringFor, ringSummary } from '../ringInfo.js'
const props = defineProps({
groups: { type: Array, default: () => [] },
unmatched: { type: Number, default: 0 },
activeRing: { type: Number, default: null },
})
const emit = defineEmits(['select', 'fullscreen'])
const T = useGiT()
const fmt = (n) => (Number(n) || 0).toLocaleString()
const summary = computed(() => ringSummary(props.groups))
const open = ref(null) // the ring whose explanation is expanded
const current = computed(() => props.activeRing ?? open.value)
function pick(ring) {
const next = current.value === ring ? null : ring
open.value = next
emit('select', next)
}
// Literal keys, one call each, so check:i18n-keys-exist can read them (no computed key names).
const bandLabel = (b) => ({
dialect: T('gospelInternet.rings.band.dialect', 'Same dialect'),
language: T('gospelInternet.rings.band.language', 'Same language'),
family: T('gospelInternet.rings.band.family', 'Same language family'),
geography: T('gospelInternet.rings.band.geography', 'Nearby only'),
})[b]
const cultureLabel = (c) => ({
same: T('gospelInternet.rings.culture.same', 'Same people'),
near: T('gospelInternet.rings.culture.near', 'Near culture'),
different: T('gospelInternet.rings.culture.different', 'Different culture'),
})[c]
const bandExplain = (b) => ({
dialect: T('gospelInternet.rings.explain.dialect', 'A reached group speaks exactly the same language variety (the same ISO 639-3 code).'),
language: T('gospelInternet.rings.explain.language', 'A reached group speaks another variety of the same language, e.g. Egyptian and Najdi Arabic.'),
family: T('gospelInternet.rings.explain.family', 'The nearest shared language is only a related one, in the same language family.'),
geography: T('gospelInternet.rings.explain.geography', 'No shared language at all: a reached group lives in the same country or within 600 km.'),
})[b]
const cultureExplain = (c) => ({
same: T('gospelInternet.rings.explain.same', 'It is the same people, living somewhere else.'),
near: T('gospelInternet.rings.explain.near', 'It is a different people in the same IMB people cluster.'),
different: T('gospelInternet.rings.explain.different', 'It is a people from a different cluster.'),
})[c]
const cellName = (ring) => {
const i = ringInfo(ring)
return i.culture ? `${bandLabel(i.band)} · ${cultureLabel(i.culture)}` : bandLabel(i.band)
}
const detail = computed(() => {
const r = current.value
if (!r) return null
const i = ringInfo(r)
return { ring: r, info: i, name: cellName(r), band: bandExplain(i.band), culture: i.culture ? cultureExplain(i.culture) : '',
count: summary.value.byRing[r].count, example: summary.value.byRing[r].example }
})
// A tiny target: 10 rings plus the cross, with THIS ring drawn in its colour.
const GLYPH_R = (ring) => 2.5 + ring * 1.15
useShadowStyles(
'.grl{font:12px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#3b463d;background:#fff;border:1px solid #d1d4cd;border-radius:10px;padding:10px;max-width:360px}' +
'.grl-h{display:flex;align-items:center;gap:8px;margin-block-end:6px}.grl-h h3{margin:0;font-size:13px;flex:1}' +
'.grl-fs{min-width:24px;min-height:24px;border:1px solid #d1d4cd;background:#fff;border-radius:6px;cursor:pointer;color:inherit}' +
'.grl-intro{margin:0 0 8px;color:#6d766e}' +
'.grl-centre{display:flex;align-items:center;gap:6px;margin-block-end:6px}' +
'.grl-grid{display:grid;grid-template-columns:auto repeat(3,1fr);gap:3px;align-items:stretch}' +
'.grl-col{font-size:11px;color:#6d766e;text-align:center;align-self:end}' +
'.grl-row{font-size:11px;font-weight:600;align-self:center;padding-inline-end:4px}' +
'.grl-cell{display:flex;flex-direction:column;align-items:center;gap:1px;min-height:44px;padding:3px;border:1px solid #e3e5e0;border-radius:6px;background:#fafaf8;cursor:pointer;color:inherit;font:inherit}' +
'.grl-cell:hover{border-color:#92b195}.grl-cell[aria-pressed="true"]{border-color:#3b463d;box-shadow:0 0 0 2px rgba(59,70,61,.18);background:#fff}' +
'.grl-cell .n{font-weight:600;font-variant-numeric:tabular-nums}.grl-cell .r{font-size:10px;color:#6d766e}' +
'.grl-cell.zero .n{color:#9aa09a}' +
'.grl-geo{grid-column:2 / span 3;flex-direction:row;justify-content:center;gap:8px}' +
'.grl-detail{margin-block-start:8px;padding:8px;border-radius:8px;background:#f3f3f1}' +
'.grl-detail b{display:block;margin-block-end:2px}.grl-detail p{margin:2px 0}' +
'.grl-ex{color:#6d766e}' +
'.grl-foot{margin-block-start:8px;color:#6d766e;font-size:11px}.grl-foot div{display:flex;justify-content:space-between;gap:8px}' +
'.grl-src{margin-block-start:6px;font-size:10px;color:#9aa09a}',
'gi-ring-legend',
)
</script>
<template>
<section class="grl" :aria-label="T('gospelInternet.rings.title', 'What the rings mean')">
<div class="grl-h">
<h3>{{ T('gospelInternet.rings.title', 'What the rings mean') }}</h3>
<button type="button" class="grl-fs" :title="T('gospelInternet.rings.fullscreen', 'Full screen')"
:aria-label="T('gospelInternet.rings.fullscreen', 'Full screen')" @click="emit('fullscreen')">⛶</button>
</div>
<p class="grl-intro">{{ T('gospelInternet.rings.intro', 'Each unreached group sits on a ring by how close it is to its nearest reached group. The closer the ring, the more naturally the gospel can travel.') }}</p>
<div class="grl-centre">
<svg width="18" height="18" viewBox="-9 -9 18 18" aria-hidden="true">
<line x1="0" y1="-7" x2="0" y2="7" :stroke="CENTRE_COLOR" stroke-width="2.2" />
<line x1="-5" y1="-2" x2="5" y2="-2" :stroke="CENTRE_COLOR" stroke-width="2.2" />
</svg>
<span>{{ T('gospelInternet.rings.centre', 'The cross: a reached group (2% or more evangelical).') }}</span>
</div>
<div class="grl-grid" role="group" :aria-label="T('gospelInternet.rings.title', 'What the rings mean')">
<span></span>
<span v-for="c in CULTURES" :key="c" class="grl-col">{{ cultureLabel(c) }}</span>
<template v-for="b in BANDS" :key="b">
<span class="grl-row">{{ bandLabel(b) }}</span>
<button v-for="c in CULTURES" :key="b + c" type="button" class="grl-cell"
:class="{ zero: !summary.byRing[ringFor(b, c)].count }"
:aria-pressed="current === ringFor(b, c) ? 'true' : 'false'"
:aria-label="cellName(ringFor(b, c)) + ': ' + fmt(summary.byRing[ringFor(b, c)].count)"
@click="pick(ringFor(b, c))">
<svg width="30" height="30" viewBox="-15 -15 30 30" aria-hidden="true">
<circle v-for="k in 10" :key="k" cx="0" cy="0" :r="GLYPH_R(k)" fill="none"
:stroke="k === ringFor(b, c) ? ringInfo(k).color : '#e3e5e0'" :stroke-width="k === ringFor(b, c) ? 2.2 : 0.6" />
<line x1="0" y1="-2.4" x2="0" y2="2.4" :stroke="CENTRE_COLOR" stroke-width="1" />
<line x1="-1.6" y1="-0.8" x2="1.6" y2="-0.8" :stroke="CENTRE_COLOR" stroke-width="1" />
</svg>
<span class="n" dir="ltr">{{ fmt(summary.byRing[ringFor(b, c)].count) }}</span>
<span class="r">{{ T('gospelInternet.rings.ring', 'Ring') }} <span dir="ltr">{{ ringFor(b, c) }}</span></span>
</button>
</template>
<span class="grl-row">{{ bandLabel('geography') }}</span>
<button type="button" class="grl-cell grl-geo" :class="{ zero: !summary.byRing[10].count }"
:aria-pressed="current === 10 ? 'true' : 'false'"
:aria-label="cellName(10) + ': ' + fmt(summary.byRing[10].count)" @click="pick(10)">
<svg width="30" height="30" viewBox="-15 -15 30 30" aria-hidden="true">
<circle v-for="k in 10" :key="k" cx="0" cy="0" :r="GLYPH_R(k)" fill="none"
:stroke="k === 10 ? ringInfo(10).color : '#e3e5e0'" :stroke-width="k === 10 ? 2.2 : 0.6" />
</svg>
<span class="n" dir="ltr">{{ fmt(summary.byRing[10].count) }}</span>
<span class="r">{{ T('gospelInternet.rings.ring', 'Ring') }} <span dir="ltr">10</span></span>
</button>
</div>
<div v-if="detail" class="grl-detail" aria-live="polite">
<b>{{ T('gospelInternet.rings.ring', 'Ring') }} <span dir="ltr">{{ detail.ring }}</span> · {{ detail.name }}</b>
<p>{{ detail.band }} <template v-if="detail.culture">{{ detail.culture }}</template></p>
<p class="grl-ex" v-if="detail.example">
{{ T('gospelInternet.rings.example', 'Example:') }} <bdi dir="ltr">{{ detail.example.label }}</bdi>.
{{ T('gospelInternet.rings.closest', 'Closest reached group:') }} <bdi dir="ltr">{{ detail.example.neighbor }}</bdi>.
</p>
<p class="grl-ex" v-else>{{ T('gospelInternet.rings.none', 'No group in this view sits on this ring.') }}</p>
<button type="button" class="grl-fs" @click="pick(detail.ring)">{{ T('gospelInternet.rings.showAll', 'Show all rings') }}</button>
</div>
<div class="grl-foot">
<div><span>{{ T('gospelInternet.rings.footCentre', 'Reached, at the centre') }}</span><b dir="ltr">{{ fmt(summary.centre) }}</b></div>
<div><span>{{ T('gospelInternet.rings.footUnplaced', 'No related reached group found, not placed') }}</span><b dir="ltr">{{ fmt(summary.unplaced) }}</b></div>
<div><span>{{ T('gospelInternet.rings.footUnmatched', 'Not in the neighbour table, not drawn') }}</span><b dir="ltr">{{ fmt(unmatched) }}</b></div>
</div>
<p class="grl-src">{{ T('gospelInternet.rings.source', 'Reached = 2% or more evangelical, the IMB definition used by peoplegroups.org. Language and culture closeness are proxies from its language codes and people clusters.') }}</p>
</section>
</template>

View file

@ -0,0 +1,56 @@
/**
* ringInfo.js — what each ring of the centered-set target MEANS, in one place, so the chart and its
* legend label, colour and explain a ring the same way.
*
* The rings are the data owner's (upg-neighbor writes `ring` onto near-neighbors.json). This file
* only names them. Rings 1-9 are a 3 × 3 grid:
* rows = how much LANGUAGE the group shares with its closest reached neighbour
* dialect (same ISO 639-3 code) · language (same language name) · family
* columns = how close the CULTURE is
* same (the same people) · near (the same IMB people cluster) · different
* and ring 10 is geography only (same country, or within 600 km). Ring = 1 + 3 × row + column.
*
* Colour: one hue per language row, darkest for the same culture, so a reader sees both axes of
* the grid in the swatch alone. Pure: no Vue, no DOM.
*/
export const BANDS = ['dialect', 'language', 'family']
export const CULTURES = ['same', 'near', 'different']
const HUES = { dialect: ['#1b5e20', '#43a047', '#a5d6a7'], language: ['#0d47a1', '#1e88e5', '#90caf9'],
family: ['#4a148c', '#8e24aa', '#ce93d8'] }
export const GEO_COLOR = '#9e9e9e'
export const CENTRE_COLOR = '#3b463d'
/** { ring, band, culture, color } for ring 1..10, or null. */
export function ringInfo(ring) {
const n = Number(ring)
if (n === 10) return { ring: 10, band: 'geography', culture: null, color: GEO_COLOR }
if (!Number.isInteger(n) || n < 1 || n > 9) return null
const row = Math.floor((n - 1) / 3), col = (n - 1) % 3
return { ring: n, band: BANDS[row], culture: CULTURES[col], color: HUES[BANDS[row]][col] }
}
/** The ring for a grid cell (band, culture); geography → 10. */
export function ringFor(band, culture) {
if (band === 'geography') return 10
return 1 + 3 * BANDS.indexOf(band) + CULTURES.indexOf(culture)
}
/**
* Per-ring counts plus one real example per ring, from the chart's own joined groups
* ({ ring, label, neighborId }), so the legend's numbers are the chart's numbers.
*/
export function ringSummary(groups) {
const out = { centre: 0, unplaced: 0, byRing: {} }
for (let r = 1; r <= 10; r++) out.byRing[r] = { count: 0, example: null }
for (const g of groups || []) {
const r = Number(g?.ring)
if (r === 0) { out.centre++; continue }
if (!(r >= 1 && r <= 10)) { out.unplaced++; continue }
const b = out.byRing[r]
b.count++
if (!b.example && g.neighborId) b.example = { label: g.label, neighbor: g.neighborId }
}
return out
}