Gospel × Internet: a ring legend that decodes the centered-set target, every ring named, counted and explained
This commit is contained in:
parent
0b81a1636d
commit
f49c81cec6
2 changed files with 231 additions and 0 deletions
175
plugin-designs/gospel-internet/src/components/GiRingLegend.vue
Normal file
175
plugin-designs/gospel-internet/src/components/GiRingLegend.vue
Normal 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>
|
||||
56
plugin-designs/gospel-internet/src/ringInfo.js
Normal file
56
plugin-designs/gospel-internet/src/ringInfo.js
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue