REGREL. Two library sites rendered RELIGION_FAMILIES values as English in every locale: the Religion legend's rows (useLegendData.js) and the search aggregate that collapses sub-types into their family (useSearch.js). Both now project the LABEL through religionFamilyLabel(), which resolves through the catalogue and keeps English as the last resort. The half worth a gate is the half that did NOT change. A translated value matches nothing — the moment a localized string lands in a filter, a bus payload or an element attribute, the link works in English and fails silently in Arabic. So `key` and `filterKey` stay the canonical family letter, useSearch's key function stays bare getReligionFamily(), and the check asserts all of that as well as the projection. RELIGION_FAMILIES is no longer imported in either file: the label was its only local use, and religionFamilyLabel reads the canonical map internally. ★ A COMMENT IN useSearch.js ARGUED AGAINST THIS FIX, and it was wrong. It read "label = family name so selection can resolve the family letter back (see research-map onGeocoderAggregateResult religion branch)" — presenting an English label as a REQUIREMENT of selection. Measured: onGeocoderAggregateResult does not exist anywhere in this repo (three comments name it, nothing defines it), and selection resolves by value — aggregate() emits `key` and `label` as separate fields, and the consumer reads detail.expectedValue (countries-map.vue:1611). Retracted in place with the measurement, not deleted, because a reader who has seen the old claim needs to know it was tested. check:religion-label-not-value is registered and negative-tested. It executes religionFamilyLabel's REAL source against the SHIPPED ar/ and en/ religion.json rather than a stub, because religion.js imports tStatic through an i18n index that calls import.meta.glob and cannot load in plain Node. Under ar the label is المسيحية while RELIGION_FAMILIES.C is still "Christianity", and a control asserts the two really differ — an English-only projection fails that assertion. One assertion of mine failed on its own retraction first: it required the stale phrase to be ABSENT, but the corrected comment quotes it in order to record it. Requiring the absence of a phrase punishes documenting the error, so it now asserts the retraction is present instead.
462 lines
25 KiB
JavaScript
462 lines
25 KiB
JavaScript
/**
|
|
* useLegendData — builds a unified, data-driven legend item tree
|
|
* for ANY legend type from a single declarative config map.
|
|
*
|
|
* Each legend type produces an array of LegendItem:
|
|
* { key, label, color, count?, population?, filterKey?, children?: LegendItem[] }
|
|
*
|
|
* The composable reads from injected Pinia stores (dataStore, uiStore, mapStore)
|
|
* so it works identically in both Desktop and Mobile legend shells.
|
|
*
|
|
* USAGE:
|
|
* const { items, columns, title, activeFilter, setFilter, totalCount, totalPopulation,
|
|
* progress } // progress: prayer-only payload for <LegendProgressBar>, else null
|
|
* = useLegendData(props.legendType)
|
|
*/
|
|
|
|
import { computed, inject } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { PRAYER_COLORS } from '../../colors/prayer-progress.js'
|
|
import { usePrayerStatistics } from '../usePrayerStatistics.js'
|
|
import { ENGAGEMENT_COLORS } from '../../colors/engagement.js'
|
|
import { ADOPTION_COLORS } from '../../colors/adoption.js'
|
|
import { getLanguageFamilyColor } from '../../colors/language-family.js'
|
|
import { getColorStrategy, COLOR_MODES } from '../../colors/_registry.js'
|
|
import { PALETTE as RELIGION_COLORS, getReligionFamily, religionFamilyLabel } from '../../colors/religion.js'
|
|
import { useLanguageFamilyLegendData } from './useLanguageFamilyLegendData.js'
|
|
|
|
const COLUMNS_SIMPLE = [] // e.g. affinity-blocs — no numeric columns
|
|
|
|
// ─── Resource palette/names (research-only strategy) ──────────────────────────
|
|
// resource is a per-map (research-only) strategy that lives in
|
|
// library/colors/resource.js (moved there in C1b) and is resolved via
|
|
// the registry (registered by the research bundle's local src/colors glob).
|
|
// Read LAZILY (inside the items computed, at call time) so registration has already
|
|
// run, and GUARDED on propertyKey — getColorStrategy silently falls back to
|
|
// language-family when a mode isn't registered on a bundle, and we must not paint
|
|
// the resource legend with the language-family palette.
|
|
function resourceStrategy() {
|
|
const strat = getColorStrategy(COLOR_MODES.RESOURCE)
|
|
return (strat && strat.propertyKey === 'resourceType') ? strat : null
|
|
}
|
|
|
|
// ─── Region / Bloc palettes (registry strategies — single source of truth) ───
|
|
// doxa-region and affinity-block are PER-MAP (research-only) strategies that
|
|
// live in library/colors/ and are discovered by _registry.js for every bundle, not via a
|
|
// research bundle's local src/colors glob. Read LAZILY (inside the items
|
|
// computed, at call time) so registration has already run, and GUARDED on
|
|
// propertyKey — getColorStrategy silently falls back to language-family when a
|
|
// mode isn't registered on a bundle, and we must not paint these legends with
|
|
// the language-family palette. The STRATEGY palettes win: the palettes
|
|
// previously inlined here had drifted from them (they were the bug).
|
|
function doxaRegionPalette() {
|
|
const strat = getColorStrategy(COLOR_MODES.DOXA_REGION)
|
|
return (strat && strat.propertyKey === 'doxaRegion') ? (strat.palette || {}) : {}
|
|
}
|
|
|
|
// The affinity-block strategy PALETTE is keyed by ROP1 CODE (A001…) while the
|
|
// legend rows are keyed by display NAME ('Arab World'…). The registry hands
|
|
// back the strategy's default export, which carries no name-keyed color map —
|
|
// so derive one at call time from its code→name (names) + code→color (palette)
|
|
// maps.
|
|
function affinityBlocColorsByName() {
|
|
const strat = getColorStrategy(COLOR_MODES.AFFINITY_BLOCK)
|
|
if (!strat || strat.propertyKey !== 'affinityBlock') return {}
|
|
const names = strat.names || {}
|
|
const palette = strat.palette || {}
|
|
const byName = {}
|
|
for (const code of Object.keys(names)) byName[names[code]] = palette[code] || '#999'
|
|
return byName
|
|
}
|
|
|
|
// ─── Helper: scan all loaded sources for features matching a predicate ────────
|
|
function scanFeatures(dataStore, filterFn) {
|
|
const allSources = Object.values(dataStore.sources || {})
|
|
const features = allSources.flatMap(src => src?.features || [])
|
|
const matched = features.filter(item => filterFn(item.properties || item))
|
|
return {
|
|
count: matched.length,
|
|
population: matched.reduce((sum, item) => {
|
|
const p = item.properties || item
|
|
return sum + (parseInt(p.population || p._raw?.Population || 0) || 0)
|
|
}, 0)
|
|
}
|
|
}
|
|
|
|
// ─── Prayer coverage by the COMMITTED metric (matches the parent site) ────────
|
|
// The prayer legend COUNTS come from usePrayerStatistics (/statistics aggregate over
|
|
// the full dataset). Population has no per-bucket authoritative value, so it is summed
|
|
// from the loaded set using the SAME `people_committed` metric — so a row's count and
|
|
// population describe one thing. 100 = the parent's full-coverage threshold
|
|
// (FULL_PRAYER_COVERAGE_COUNT in server/api/people-groups/statistics.get.ts).
|
|
const FULL_PRAYER_COMMITTED = 100
|
|
function committedOf(p) {
|
|
let v = p?.peopleCommitted ?? p?._raw?.people_committed ?? p?.people_committed ?? null
|
|
if (v && typeof v === 'object') v = v.value ?? v.label ?? 0
|
|
return Number(v) || 0
|
|
}
|
|
function committedLevel(level) {
|
|
return (p) => {
|
|
const c = committedOf(p)
|
|
if (level === 'fullPrayer') return c >= FULL_PRAYER_COMMITTED
|
|
if (level === 'hasPrayer') return c > 0 && c < FULL_PRAYER_COMMITTED
|
|
return c <= 0 // noPrayer
|
|
}
|
|
}
|
|
|
|
// ─── Engagement / Adoption checkers ───────────────────────────────────────────
|
|
function checkHasEngagement(props) {
|
|
const val = props.engagementStatus ?? props._raw?.people_committed
|
|
return val === true || val === 1 || val === '1' || val === 'true'
|
|
|| (typeof val === 'number' && val > 0)
|
|
|| (typeof val === 'string' && parseInt(val, 10) > 0)
|
|
}
|
|
function checkHasAdoption(props) {
|
|
const val = props.adoptionStatus ?? props._raw?.adopted_by_churches
|
|
return val === true || val === 1 || val === '1' || val === 'true'
|
|
|| (typeof val === 'number' && val > 0)
|
|
|| (typeof val === 'string' && parseInt(val, 10) > 0)
|
|
}
|
|
|
|
// ─── Title i18n-key map ───────────────────────────────────────────────────────
|
|
// Maps legend type → i18n key under `legend.title.*`.
|
|
const TITLE_KEYS = {
|
|
'prayer': 'legend.title.prayer',
|
|
'engagement': 'legend.title.engagement',
|
|
'adoption': 'legend.title.adoption',
|
|
'language-families': 'legend.title.languageFamilies',
|
|
// 'language-family' (singular) is the NEW expand/collapse tree variant —
|
|
// its rows come from useLanguageFamilyLegendData. Title falls back to the
|
|
// plural i18n key so existing translations work.
|
|
'language-family': 'legend.title.languageFamilies',
|
|
'doxa-regions': 'legend.title.doxaRegions',
|
|
// Alias: research-map profile uses 'doxa-region' (singular) for symmetry
|
|
// with 'language-family'. Resolved to the same data path below.
|
|
'doxa-region': 'legend.title.doxaRegions',
|
|
'affinity-blocs': 'legend.title.affinityBlocs',
|
|
// COUNTRIES — the country-posters map's default level. No i18n key yet; the
|
|
// title falls back to the literal "Countries" the same way religion does.
|
|
// Both singular and plural resolve to one canonical key below, matching the
|
|
// doxa-region / language-family precedent.
|
|
'countries': 'legend.title.countries',
|
|
'country': 'legend.title.countries',
|
|
'gospel-resources': 'legend.title.gospelResources',
|
|
// No i18n key for religion in v1 — title falls back to the literal "Religion"
|
|
// via the title computed below. Add `legend.title.religion` translations
|
|
// when localizing the religion tab.
|
|
'religion': 'legend.title.religion'
|
|
}
|
|
|
|
// ─── Legend-type alias normalizer ─────────────────────────────────────────────
|
|
// Profiles may use either singular or plural forms; normalize so the items
|
|
// computed below can use a single canonical key per type.
|
|
function normalizeLegendType(t) {
|
|
if (t === 'doxa-region') return 'doxa-regions'
|
|
if (t === 'country') return 'countries'
|
|
return t
|
|
}
|
|
|
|
// ─── Format helpers ───────────────────────────────────────────────────────────
|
|
function fmtPop(pop) {
|
|
if (!pop) return '0'
|
|
if (pop >= 1_000_000) return (pop / 1_000_000).toFixed(1) + 'M'
|
|
if (pop >= 1_000) return (pop / 1_000).toFixed(1) + 'K'
|
|
return pop.toLocaleString()
|
|
}
|
|
function fmtCount(n) { return (Number(n) || 0).toLocaleString() }
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// Main composable
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
export function useLegendData(legendTypeRef, opts = {}) {
|
|
const dataStore = inject('dataStore')
|
|
const uiStore = inject('uiStore')
|
|
const mapStore = inject('mapStore')
|
|
const { t: $t } = useI18n()
|
|
|
|
// Authoritative prayer-coverage counts (single source of truth shared by every
|
|
// profile). Kick off the one /statistics fetch; the `items` computed reads its
|
|
// reactive `loaded` flag, so the legend updates to 601/1505/0 the moment it resolves.
|
|
const prayerStats = usePrayerStatistics()
|
|
prayerStats.load()
|
|
|
|
// ── Title ─────────────────────────────────────────────────────────────────
|
|
const title = computed(() => {
|
|
const _t = normalizeLegendType(legendTypeRef.value)
|
|
if (_t === 'religion') return 'Religion' // literal fallback until i18n added
|
|
const key = TITLE_KEYS[legendTypeRef.value]
|
|
return key ? $t(key) : ''
|
|
})
|
|
|
|
// ── Columns ───────────────────────────────────────────────────────────────
|
|
// Column labels translated at read-time so locale switches re-render headers.
|
|
const columns = computed(() => {
|
|
const type = normalizeLegendType(legendTypeRef.value)
|
|
if (type === 'affinity-blocs') return COLUMNS_SIMPLE
|
|
return [
|
|
{ key: 'count', label: $t('legend.columns.upgs'), width: 48 },
|
|
{ key: 'population', label: $t('legend.columns.population'), width: 48 }
|
|
]
|
|
})
|
|
|
|
// ── Active filter key (for highlight ring on selected row) ────────────────
|
|
const activeFilter = computed(() => {
|
|
const t = normalizeLegendType(legendTypeRef.value)
|
|
if (t === 'prayer') return uiStore.prayerFilter
|
|
if (t === 'engagement') return uiStore.engagementFilter
|
|
if (t === 'adoption') return uiStore.adoptionFilter
|
|
if (t === 'language-families') return mapStore.selectedFamily
|
|
if (t === 'doxa-regions') return mapStore.selectedRegion
|
|
if (t === 'gospel-resources') return mapStore.selectedResource
|
|
if (t === 'religion') return uiStore.religionFilter
|
|
return null
|
|
})
|
|
|
|
// ── Set filter (called when a row is clicked) ─────────────────────────────
|
|
function setFilter(filterKey) {
|
|
const t = normalizeLegendType(legendTypeRef.value)
|
|
if (t === 'prayer') {
|
|
uiStore.setPrayerFilter(uiStore.prayerFilter === filterKey ? null : filterKey)
|
|
} else if (t === 'engagement') {
|
|
uiStore.setEngagementFilter(uiStore.engagementFilter === filterKey ? null : filterKey)
|
|
} else if (t === 'adoption') {
|
|
uiStore.setAdoptionFilter(uiStore.adoptionFilter === filterKey ? null : filterKey)
|
|
} else if (t === 'language-families') {
|
|
mapStore.selectFamily(mapStore.selectedFamily === filterKey ? null : filterKey)
|
|
} else if (t === 'doxa-regions') {
|
|
mapStore.selectRegion(mapStore.selectedRegion === filterKey ? null : filterKey)
|
|
} else if (t === 'gospel-resources') {
|
|
mapStore.selectResource(mapStore.selectedResource === filterKey ? null : filterKey)
|
|
} else if (t === 'religion') {
|
|
uiStore.setReligionFilter(uiStore.religionFilter === filterKey ? null : filterKey)
|
|
}
|
|
}
|
|
|
|
// ── Items tree ────────────────────────────────────────────────────────────
|
|
const items = computed(() => {
|
|
const t = normalizeLegendType(legendTypeRef.value)
|
|
|
|
// ── Prayer (3-tier: parent + 2 children) ────────────────────────────────
|
|
if (t === 'prayer') {
|
|
// COUNT = the authoritative /statistics aggregate (committed metric, full
|
|
// dataset) — the single source of truth the parent site uses, NOT a client
|
|
// scan of loaded pins (which under-counted to 435 vs the parent's 601).
|
|
// Population is summed from the loaded set on the SAME committed metric so
|
|
// each row's count + population agree. Until /statistics resolves, fall back
|
|
// to the committed scan so the legend is never blank.
|
|
const noScan = scanFeatures(dataStore, committedLevel('noPrayer'))
|
|
const hasScan = scanFeatures(dataStore, committedLevel('hasPrayer'))
|
|
const fullScan = scanFeatures(dataStore, committedLevel('fullPrayer'))
|
|
const ready = prayerStats.loaded.value
|
|
const noCount = ready ? prayerStats.noPrayerCount() : noScan.count
|
|
const hasCount = ready ? prayerStats.hasPrayerCount() : hasScan.count
|
|
const fullCount = ready ? prayerStats.fullPrayerCount() : fullScan.count
|
|
return [{
|
|
key: 'noPrayer', label: $t('legend.prayer.needsPrayer'), color: PRAYER_COLORS.noPrayer,
|
|
count: noCount, population: noScan.population, filterKey: 'noPrayer',
|
|
children: [
|
|
{ key: 'hasPrayer', label: $t('legend.prayer.hasPrayer'), color: PRAYER_COLORS.hasPrayer,
|
|
count: hasCount, population: hasScan.population, filterKey: 'hasPrayer' },
|
|
{ key: 'fullPrayer', label: $t('legend.prayer.fullPrayerCoverage'), color: PRAYER_COLORS.fullPrayer,
|
|
count: fullCount, population: fullScan.population, filterKey: 'fullPrayer' }
|
|
]
|
|
}]
|
|
}
|
|
|
|
// ── Engagement (binary parent/child) ────────────────────────────────────
|
|
if (t === 'engagement') {
|
|
const no = scanFeatures(dataStore, p => !checkHasEngagement(p))
|
|
const has = scanFeatures(dataStore, p => checkHasEngagement(p))
|
|
return [{
|
|
key: 'notEngaged', label: $t('legend.engagement.needsEngagement'), color: ENGAGEMENT_COLORS.notEngaged,
|
|
count: no.count, population: no.population, filterKey: 'notEngaged',
|
|
children: [
|
|
{ key: 'hasEngagement', label: $t('legend.engagement.hasEngagement'), color: ENGAGEMENT_COLORS.hasEngagement,
|
|
count: has.count, population: has.population, filterKey: 'hasEngagement' }
|
|
]
|
|
}]
|
|
}
|
|
|
|
// ── Adoption (binary parent/child) ──────────────────────────────────────
|
|
if (t === 'adoption') {
|
|
const no = scanFeatures(dataStore, p => !checkHasAdoption(p))
|
|
const has = scanFeatures(dataStore, p => checkHasAdoption(p))
|
|
return [{
|
|
key: 'notAdopted', label: $t('legend.adoption.needsAdoption'), color: ADOPTION_COLORS.notAdopted,
|
|
count: no.count, population: no.population, filterKey: 'notAdopted',
|
|
children: [
|
|
{ key: 'hasAdoption', label: $t('legend.adoption.hasAdoption'), color: ADOPTION_COLORS.hasAdoption,
|
|
count: has.count, population: has.population, filterKey: 'hasAdoption' }
|
|
]
|
|
}]
|
|
}
|
|
|
|
// ── Religion (flat list, one row per religion family) ──────────────────
|
|
if (t === 'religion') {
|
|
// Roll up by family letter (first char of religion code: C/M/H/B/E/J/S/N/O/U).
|
|
// Each row gets a count + population summed across all sub-religions in that family.
|
|
const familyAggregates = {}
|
|
const allSources = Object.values(dataStore.sources || {})
|
|
const features = allSources.flatMap(src => src?.features || [])
|
|
for (const item of features) {
|
|
const p = item.properties || item
|
|
const code = p.religion || p._raw?.religion?.value || p._raw?.religion || ''
|
|
const fam = getReligionFamily(code)
|
|
if (!familyAggregates[fam]) familyAggregates[fam] = { count: 0, population: 0 }
|
|
familyAggregates[fam].count += 1
|
|
familyAggregates[fam].population += parseInt(p.population || p._raw?.Population || 0) || 0
|
|
}
|
|
// Christianity always first; rest sorted by population desc.
|
|
// Optional family-letter exclusions (e.g. ['C'] on the unengaged-PG dataset
|
|
// — Christianity-followers aren't unengaged so the row would be misleading).
|
|
// Configured via RESEARCH_LEGEND_OPTIONS.excludeReligionFamilies.
|
|
const exclude = new Set(opts?.excludeReligionFamilies || [])
|
|
for (const fam of exclude) delete familyAggregates[fam]
|
|
const order = Object.keys(familyAggregates).sort((a, b) => {
|
|
if (a === 'C') return -1
|
|
if (b === 'C') return 1
|
|
return (familyAggregates[b].population - familyAggregates[a].population)
|
|
})
|
|
return order.map(fam => ({
|
|
key: fam,
|
|
// REGREL: the LABEL is localized, the VALUE is not. `key` and `filterKey` below stay the
|
|
// canonical family letter, which is what the filter and the map compare on; only this
|
|
// display string follows the reader's language. RELIGION_FAMILIES is no longer imported here:
|
|
// religionFamilyLabel() reads it internally, so this file's only use of it was this label.
|
|
label: religionFamilyLabel(fam) || 'Unknown',
|
|
color: RELIGION_COLORS[fam] || '#94a3b8',
|
|
count: familyAggregates[fam].count,
|
|
population: familyAggregates[fam].population,
|
|
filterKey: fam,
|
|
children: []
|
|
}))
|
|
}
|
|
|
|
// ── Language Families (flat list, each is a "parent" with no children) ───
|
|
if (t === 'language-families') {
|
|
return (dataStore.languageFamiliesData || []).map(f => ({
|
|
key: f.name, label: f.name, color: getLanguageFamilyColor(f.name),
|
|
count: f.count, population: f.population, filterKey: f.name,
|
|
children: []
|
|
}))
|
|
}
|
|
|
|
// ── Language Family TREE (singular) — delegate row construction to the
|
|
// dedicated composable. The returned shape is the LegendRow shape from
|
|
// useLanguageFamilyLegendData ({ peopleGroupCount, ..., kind, pinIds }).
|
|
// LegendDesktop / LegendMobile branch on `legendType === 'language-family'`
|
|
// BEFORE consuming items, so this is a fallback for any caller that still
|
|
// reads `items` directly (e.g. a custom host).
|
|
if (t === 'language-family') {
|
|
const features = Object.values(dataStore.sources || {})
|
|
.flatMap(src => src?.features || [])
|
|
const featuresRef = { value: features }
|
|
const { rows: famRows } = useLanguageFamilyLegendData(featuresRef)
|
|
return famRows.value.map(r => ({
|
|
key: r.key, label: r.label, color: r.color,
|
|
count: r.peopleGroupCount, population: r.population,
|
|
filterKey: r.key, children: []
|
|
}))
|
|
}
|
|
|
|
// ── Doxa Regions (flat) ─────────────────────────────────────────────────
|
|
if (t === 'doxa-regions') {
|
|
const REGION_COLORS = doxaRegionPalette() // lazy+guarded registry read
|
|
return (dataStore.doxaRegionsData || []).map(r => ({
|
|
key: r.name, label: r.name, color: REGION_COLORS[r.name] || '#999',
|
|
count: r.count, population: r.population, filterKey: r.name,
|
|
children: []
|
|
}))
|
|
}
|
|
|
|
// ── Gospel Resources (parent/child tree) ────────────────────────────────
|
|
if (t === 'gospel-resources') {
|
|
// Lazy + guarded: research-only strategy, present only on bundles that
|
|
// register it locally. Empty legend (not wrong colors) when absent.
|
|
const strat = resourceStrategy()
|
|
const RESOURCE_COLORS = strat?.palette ?? {}
|
|
const RESOURCE_NAMES = strat?.names ?? {}
|
|
const resources = dataStore.resourcesData || []
|
|
return Object.keys(RESOURCE_COLORS).map(rKey => {
|
|
const data = resources.find(r => r.key === rKey)
|
|
return {
|
|
key: rKey, label: RESOURCE_NAMES[rKey] || rKey, color: RESOURCE_COLORS[rKey],
|
|
count: data?.count || 0, population: data?.population || 0, filterKey: rKey,
|
|
children: (data?.children || []).map(c => ({
|
|
key: c.key, label: c.name || c.key, color: c.color || RESOURCE_COLORS[rKey],
|
|
count: c.count || 0, population: c.population || 0, filterKey: c.key
|
|
}))
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── Affinity Blocks (flat, no counts) ────────────────────────────────────
|
|
if (t === 'affinity-blocs') {
|
|
// lazy+guarded registry read; sorted to keep the alphabetical row order
|
|
// the old inlined (name-keyed) palette produced.
|
|
const BLOC_COLORS = affinityBlocColorsByName()
|
|
return Object.keys(BLOC_COLORS).sort().map(b => ({
|
|
key: b, label: b, color: BLOC_COLORS[b], filterKey: b, children: []
|
|
}))
|
|
}
|
|
|
|
return []
|
|
})
|
|
|
|
// ── Totals ────────────────────────────────────────────────────────────────
|
|
function sumTree(arr, field) {
|
|
return arr.reduce((s, item) => {
|
|
let v = item[field] || 0
|
|
if (item.children?.length) v += sumTree(item.children, field)
|
|
return s + v
|
|
}, 0)
|
|
}
|
|
const totalCount = computed(() => sumTree(items.value, 'count'))
|
|
const totalPopulation = computed(() => sumTree(items.value, 'population'))
|
|
|
|
// ── Prayer progress (header bar payload) ──────────────────────────────────
|
|
// Payload for <LegendProgressBar> (library/components/legends/LegendProgressBar.vue)
|
|
// — PRAYER ONLY, null for every other legend type (the consumers render nothing
|
|
// on null). Hosts pass it as the `progress` prop of SemanticTreeLegend / LegendRows
|
|
// AND provide('legendProgress', progress) so the mobile sheet's embedded legend
|
|
// (LegendMobile → SemanticTreeLegend) picks it up via inject.
|
|
// The three prayer row counts above are mutually EXCLUSIVE (usePrayerStatistics:
|
|
// partial excludes full; committedLevel: has = 0 < committed < 100), so the bar's
|
|
// has-prayer SPAN = has + full and full FILLS that span from the left (full ⊂ has).
|
|
// covered = has + full (the span; drives "covered/total · %")
|
|
// total = totalCount (no + has + full = /statistics `total`
|
|
// once loaded — every people group in the data source; the loaded-pin
|
|
// count until then)
|
|
// segments = [full, has, no] LEFT→RIGHT, exclusive counts, prayer-strategy colours
|
|
const progress = computed(() => {
|
|
if (normalizeLegendType(legendTypeRef.value) !== 'prayer') return null
|
|
const row = items.value[0]
|
|
if (!row) return null
|
|
const has = (row.children || []).find(c => c.key === 'hasPrayer')
|
|
const full = (row.children || []).find(c => c.key === 'fullPrayer')
|
|
const noCount = Number(row.count) || 0
|
|
const hasCount = Number(has?.count) || 0
|
|
const fullCount = Number(full?.count) || 0
|
|
// Population of the covered span, so the progress row can fill the Population column
|
|
// with the same number the Has Prayer + Full Prayer rows add up to.
|
|
const coveredPopulation = (Number(has?.population) || 0) + (Number(full?.population) || 0)
|
|
return {
|
|
covered: hasCount + fullCount,
|
|
total: totalCount.value,
|
|
coveredPopulation,
|
|
totalPopulation: totalPopulation.value,
|
|
segments: [
|
|
{ key: 'fullPrayer', label: full?.label ?? $t('legend.prayer.fullPrayerCoverage'), count: fullCount, color: PRAYER_COLORS.fullPrayer },
|
|
{ key: 'hasPrayer', label: has?.label ?? $t('legend.prayer.hasPrayer'), count: hasCount, color: PRAYER_COLORS.hasPrayer },
|
|
{ key: 'noPrayer', label: row.label, count: noCount, color: PRAYER_COLORS.noPrayer }
|
|
]
|
|
}
|
|
})
|
|
|
|
return {
|
|
items, columns, title, activeFilter, setFilter,
|
|
totalCount, totalPopulation,
|
|
progress,
|
|
fmtPop, fmtCount
|
|
}
|
|
}
|