Prayer legend header shows progress: a hoverable three-colour bar and the title "Progress"
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

- New shared LegendProgressBar: [has-prayer span | needs-prayer remainder] with the
  full-coverage colour filling inside the has-prayer span; text "covered of total · %";
  each span is a focusable button with the legend's info popover (name · count · %),
  hover highlight, and click selects the matching legend row
- useLegendData exposes a `progress` payload for the prayer legend (mutually exclusive
  counts from /statistics: covered = has + full); null for every other legend
- SemanticTreeLegend and LegendRows render the bar as the header's second line and title
  the prayer legend "Progress" (desktop, mobile sheet, collapsed pill); profiles pass and
  provide the payload so the mobile sheet gets it unchanged
- legend.progress.* strings in all 11 locales
This commit is contained in:
swift-river542 2026-09-16 16:36:14 -05:00
parent 271ba0849c
commit fff25e750a
18 changed files with 430 additions and 8 deletions

View file

@ -820,6 +820,12 @@ const REGIONS_TABS = [
// Flat legend data for prayer/adoption/engagement/religion tabs — converted to
// STL-compatible tree nodes so ALL tabs render through the same SemanticTreeLegend.
const flatLegend = useFlatLegendData(toRef(() => activeLegendType.value), RESEARCH_LEGEND_OPTIONS)
// Prayer progress bar (header of the Prayer tab's legend; Driver 2026-09-16). null on
// every other tab → no bar. Passed as a prop to the desktop STL AND provide()d so the
// MOBILE sheet's embedded <SemanticTreeLegend> (LegendMobile.vue, untouched) picks it
// up via inject('legendProgress'). STL also retitles itself "Progress" when present.
const legendProgress = flatLegend.progress
provide('legendProgress', legendProgress)
function _flatItemsToStlNodes(items) {
const out = []
for (const item of items) {
@ -2132,6 +2138,7 @@ onBeforeUnmount(() => {
:title="legendTitle"
:hideTabs="legendHideTabs"
:columnLabel="legendColumnLabel"
:progress="legendProgress"
:open="_stlOpen"
:export-enabled="EXPORT_FEATURE_ENABLED"
@select="onSemanticTreeSelect"

View file

@ -342,6 +342,13 @@ function _flatItemsToStlNodes(items) {
}
const legendNodes = computed(() => _flatItemsToStlNodes(flatLegend.items.value))
const legendTitle = computed(() => 'Legend')
// Prayer progress bar (header of the prayer legend; Driver 2026-09-16). The payload
// is null on every non-prayer tab → no bar. Passed as a prop to the desktop STL
// below AND provide()d so the MOBILE sheet's embedded <SemanticTreeLegend>
// (LegendMobile.vue, untouched) picks it up via inject('legendProgress') — one
// wiring for desktop, mobile and embeds. STL also retitles itself "Progress".
const legendProgress = flatLegend.progress
provide('legendProgress', legendProgress)
const legendColumnLabel = computed(() => {
const t = activeLegendType.value
if (t === 'prayer') return 'Prayer Status'
@ -1391,6 +1398,7 @@ onBeforeUnmount(() => {
:title="legendTitle"
:hideTabs="true"
:columnLabel="legendColumnLabel"
:progress="legendProgress"
:open="_stlOpen"
@select="onSemanticTreeSelect"
@update:open="_onStlOpenChange"

View file

@ -217,6 +217,7 @@ const legendTypeRef = toRef(props, 'legendType')
const {
items: legendDataItems, columns: legendColumns, title: legendDataTitle,
activeFilter, setFilter, totalCount, totalPopulation,
progress: legendProgress, // prayer-only header bar payload (null otherwise)
fmtPop, fmtCount
} = useLegendData(legendTypeRef, RESEARCH_LEGEND_OPTIONS)
@ -413,6 +414,7 @@ onBeforeUnmount(() => {
:totalPopulation="totalPopulation"
:fmtPop="fmtPop"
:fmtCount="fmtCount"
:progress="legendProgress"
@filter-click="handleRowFilterClick"
>
<template #title-caret>

View file

@ -0,0 +1,225 @@
<script setup>
/**
* LegendProgressBar — the prayer legend's header progress bar.
*
* WHY (Driver, 2026-09-16, README story): a global prayer movement needs to SEE
* progress — how many people groups are covered in prayer out of the total, changing
* live as people sign up. ONE shared component so every prayer map (simple map
* prayer tab, research map Prayer tab, and embeds) shows the same bar.
*
* SPEC (Driver refinement, 2026-09-16 — keep this in sync with the behaviour):
* 1. The bar lives in the HEADER of the prayer legend, and the prayer legend's
* title becomes "Progress" (desktop title bar and the mobile sheet title both —
* via the shared components SemanticTreeLegend / LegendRows, never "Legend").
* 2. Three colours = the three prayer legend rows (Has Prayer, Needs Prayer, Full
* Prayer Coverage) — the EXACT colours of the prayer colour strategy
* (library/colors/prayer-progress.js PALETTE) that the legend rows use.
* 3. Geometry, left → right: [has-prayer span][needs-prayer span = the remainder].
* Full prayer coverage is NOT a third separate segment: it fills IN the
* has-prayer span from the left in the full-coverage colour (full ⊂ has prayer:
* "has prayer and full prayer coverage take up one slot combined, not
* duplicated"), so over time the full-coverage colour grows to fill the
* has-prayer span. useLegendData's three row counts are mutually EXCLUSIVE
* (usePrayerStatistics: partial excludes full; the scan fallback: has =
* 0 < committed < 100), therefore hasSpan = has + full and fullFill = full.
* Widths are percentages of totalCount.
* 4. Text line: covered/total + %, where covered = the has-prayer span.
* 5. Compact — the header stays one or two lines; dark variant for the research map.
* 6. Interactive + hoverable: hovering (and keyboard-focusing) a span shows what it
* is and its numbers ("Has Prayer · 658 · 31%") as a small popover that imitates
* the existing .stl-info-pop chrome (same font/colours, dark + light), plus a
* hover highlight on the span. A click on a span selects the matching legend row
* (the host wires @select-segment to the SAME handler its rows use).
* The typical legend rows stay right under the header.
*
* API:
* props { covered, total, segments, isDark, label, fmtCount }
* covered Number — the has-prayer span count (has + full). Drives the text + aria.
* total Number — totalCount of the legend (all people groups in the data source).
* segments Array — [{ key, label, count, color }] in LEFT→RIGHT draw order with
* EXCLUSIVE counts (they sum to `total`). Every segment but the
* LAST is drawn inside the covered span (so for prayer:
* [fullPrayer, hasPrayer, noPrayer] → green fills the left of
* the orange span, red is the remainder).
* isDark Boolean — dark chrome (research map) vs light.
* label String — optional override of the accessible full sentence.
* fmtCount Function— the legend's number formatter (defaults to toLocaleString).
* emits 'select-segment' (key) — the hovered/clicked segment's key (a legend row key).
*
* Accessibility: the visible text carries role="progressbar" + aria-valuenow/min/max
* with the full sentence as its aria-label (a progressbar's children are presentational
* in ARIA, so the role must NOT wrap the interactive segment buttons). Each segment is a
* real <button> with its own aria-label, so keyboard users get the same popover.
*
* Styles are injected with useShadowStyles like every other legend component.
* Payload builder: useLegendData(...).progress (library/composables/legends/useLegendData.js).
*/
import { ref, computed, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const { t } = useI18n()
useShadowStyles(`
/* ── LegendProgressBar (.lpb) — header progress bar of the prayer legend.
One flex line: [track (flex:1)][compact text]. The popover is absolute, below
the track, and imitates .stl-info-pop (SemanticTreeLegend) so it reads as
part of the legend chrome. Dark is the STL default; .lpb-light mirrors the
.stl-panel[data-theme="light"] palette. ── */
.lpb{position:relative;display:flex;align-items:center;gap:8px;min-width:0;width:100%;box-sizing:border-box;}
.lpb-track{position:relative;flex:1 1 auto;min-width:60px;display:flex;height:10px;border-radius:5px;overflow:hidden;background:rgba(110,118,129,0.25);}
.lpb-span{display:flex;height:100%;min-width:0;flex:0 0 auto;}
.lpb-seg{appearance:none;-webkit-appearance:none;border:none;margin:0;padding:0;height:100%;display:block;flex:0 0 auto;cursor:pointer;outline:none;-webkit-tap-highlight-color:transparent;transition:filter 0.12s,box-shadow 0.12s;}
.lpb-seg-rest{flex:1 1 auto;}
.lpb-seg:focus{outline:none;}
/* Hover / keyboard-focus / active highlight — brighter + a 1px inset ring (inset
shadows survive the track's overflow:hidden). */
.lpb-seg:hover,.lpb-seg:focus-visible,.lpb-seg.active{filter:brightness(1.18);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.65);}
.lpb-light .lpb-seg:hover,.lpb-light .lpb-seg:focus-visible,.lpb-light .lpb-seg.active{box-shadow:inset 0 0 0 1px rgba(0,0,0,0.35);}
/* The text — same mono/size family as the STL column headers, tabular digits. */
.lpb-text{flex:0 0 auto;font:600 10px ui-monospace,monospace;letter-spacing:0.02em;color:#8b949e;white-space:nowrap;font-variant-numeric:tabular-nums;line-height:1;}
.lpb-light .lpb-text{color:#57606a;}
/* Popover — imitates .stl-info-pop (same font, colours, radius, border). */
.lpb-pop{position:absolute;top:calc(100% + 6px);left:0;z-index:10;padding:7px 10px;background:#0d1117;border:1px solid #30363d;border-radius:6px;font:11px system-ui;color:#c9d1d9;line-height:1.45;white-space:nowrap;pointer-events:none;box-shadow:0 8px 24px rgba(0,0,0,0.5);}
.lpb-light .lpb-pop{background:#f6f8fa;border-color:#d0d7de;color:#1f2328;box-shadow:0 8px 24px rgba(31,35,40,0.18);}
.lpb-pop-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:-1px;box-shadow:0 0 0 1px rgba(0,0,0,0.4);}
/* Collapsed mobile sheet (LegendMobile.vue) shows only the 48px title strip — the
bar must not take a second header line there. Same-shadow-root rule, owned here
so the sheet needs no knowledge of the bar. */
.legend-mobile-sheet.collapsed .lpb{display:none!important;}
`, 'legend-progress-bar')
const props = defineProps({
/** Has-prayer span count (has + full) — drives the text and the aria values. */
covered: { type: Number, default: 0 },
/** totalCount of the legend (every people group in the data source). */
total: { type: Number, default: 0 },
/** [{ key, label, count, color }] LEFT→RIGHT, exclusive counts; last = remainder. */
segments: { type: Array, default: () => [] },
isDark: { type: Boolean, default: false },
/** Optional override of the accessible full sentence. */
label: { type: String, default: '' },
/** The legend's number formatter (useLegendData.fmtCount / STL fmtCount). */
fmtCount: { type: Function, default: (n) => (Number(n) || 0).toLocaleString() }
})
const emit = defineEmits(['select-segment'])
// ── Numbers ───────────────────────────────────────────────────────────────────
const pct = computed(() => props.total > 0 ? Math.round(props.covered / props.total * 100) : 0)
// Covered span width as % of the track (clamped — counts can lag a beat while
// /statistics resolves against the loaded-pin fallback).
const coveredPct = computed(() => props.total > 0 ? Math.min(100, Math.max(0, props.covered / props.total * 100)) : 0)
const spanSegs = computed(() => props.segments.slice(0, -1))
const restSeg = computed(() => props.segments.length ? props.segments[props.segments.length - 1] : null)
// A span segment's width is relative to the SPAN (so the segments inside it sum to
// the span), which equals count/total of the track.
function segPctOfSpan(seg) {
return props.covered > 0 ? Math.min(100, Math.max(0, (seg.count || 0) / props.covered * 100)) : 0
}
function segPct(seg) {
return props.total > 0 ? Math.round((seg.count || 0) / props.total * 100) : 0
}
// ── Text (i18n: legend.progress.* in library/i18n/locales/<lc>/legend.json) ──
const fullText = computed(() => props.label || t('legend.progress.covered', {
covered: props.fmtCount(props.covered), total: props.fmtCount(props.total), pct: pct.value
}))
const shortText = computed(() => t('legend.progress.short', {
covered: props.fmtCount(props.covered), total: props.fmtCount(props.total), pct: pct.value
}))
function segText(seg) {
return t('legend.progress.segment', { label: seg.label, count: props.fmtCount(seg.count || 0), pct: segPct(seg) })
}
// ── Hover / focus popover ─────────────────────────────────────────────────────
const rootEl = ref(null)
const popEl = ref(null)
const segEls = {}
const activeKey = ref(null)
const popLeft = ref(0)
const activeSeg = computed(() => props.segments.find(s => s.key === activeKey.value) || null)
function setSegEl(key, el) { if (el) segEls[key] = el; else delete segEls[key] }
async function show(key) {
activeKey.value = key
await nextTick()
// Anchor the popover at the segment's left edge, clamped inside the bar's width
// so it never pokes out of the legend panel (whose .stl-inner clips overflow).
const root = rootEl.value, seg = segEls[key], pop = popEl.value
if (!root || !seg || !pop) return
const rr = root.getBoundingClientRect(), sr = seg.getBoundingClientRect()
const maxLeft = Math.max(0, rr.width - pop.offsetWidth)
popLeft.value = Math.min(Math.max(0, sr.left - rr.left), maxLeft)
}
function hide() { activeKey.value = null }
</script>
<template>
<div
ref="rootEl"
class="lpb"
:class="isDark ? 'lpb-dark' : 'lpb-light'"
:data-covered="covered"
:data-total="total"
:data-pct="pct"
@mouseleave="hide"
>
<div class="lpb-track">
<!-- The covered (has-prayer) span: width = covered/total. Inside it the
segments are drawn left→right, so for prayer the full-coverage colour
fills the span from the left and the has-prayer colour completes it. -->
<span class="lpb-span" :style="{ width: coveredPct + '%' }" :data-span-pct="coveredPct.toFixed(2)">
<button
v-for="seg in spanSegs" :key="seg.key"
type="button"
class="lpb-seg"
:class="{ active: activeKey === seg.key }"
:style="{ width: segPctOfSpan(seg) + '%', background: seg.color }"
:data-seg="seg.key"
:data-count="seg.count || 0"
:aria-label="segText(seg)"
:ref="el => setSegEl(seg.key, el)"
@mouseenter="show(seg.key)"
@focus="show(seg.key)"
@blur="hide"
@click.stop="emit('select-segment', seg.key)"
></button>
</span>
<!-- The remainder (needs prayer) — fills whatever the span leaves. -->
<button
v-if="restSeg"
type="button"
class="lpb-seg lpb-seg-rest"
:class="{ active: activeKey === restSeg.key }"
:style="{ background: restSeg.color }"
:data-seg="restSeg.key"
:data-count="restSeg.count || 0"
:aria-label="segText(restSeg)"
:ref="el => setSegEl(restSeg.key, el)"
@mouseenter="show(restSeg.key)"
@focus="show(restSeg.key)"
@blur="hide"
@click.stop="emit('select-segment', restSeg.key)"
></button>
</div>
<span
class="lpb-text"
role="progressbar"
:aria-valuenow="covered"
aria-valuemin="0"
:aria-valuemax="total"
:aria-valuetext="shortText"
:aria-label="fullText"
:title="fullText"
>{{ shortText }}</span>
<div v-if="activeSeg" ref="popEl" class="lpb-pop" role="tooltip" :style="{ left: popLeft + 'px' }">
<span class="lpb-pop-dot" :style="{ background: activeSeg.color }"></span>{{ segText(activeSeg) }}
</div>
</div>
</template>
<style scoped>
/* All styles injected via useShadowStyles for shadow DOM compatibility. */
</style>

View file

@ -15,8 +15,22 @@
col 2, column headers auto-placed into remaining tracks. -->
<div v-if="!hideColumnHeader && (title || columns.length)" class="lrg-title-row">
<span class="lrg-title-caret-slot"><slot name="title-caret" /></span>
<span class="lrg-title">{{ title }}</span>
<span class="lrg-title">{{ displayTitle }}</span>
<span v-for="col in columns" :key="col.key" class="lrg-header-col">{{ col.label }}</span>
<!-- Prayer progress bar — second line of the (sticky) header row, spanning
the pill columns. Rendered only when a progress payload exists (prayer
legend); the title above then reads "Progress". Clicking a span selects
the matching row through the SAME filter-click the rows emit. -->
<LegendProgressBar
v-if="progressData"
class="lrg-progress"
:covered="progressData.covered"
:total="progressData.total"
:segments="progressData.segments"
:is-dark="isDark"
:fmt-count="fmtCount"
@select-segment="key => $emit('filter-click', key)"
/>
</div>
<!-- Data rows — colored pills, clickable. The .lrg-item / .lrg-item-inner
@ -90,9 +104,10 @@
</template>
<script setup>
import { reactive, computed, ref, watch, nextTick } from 'vue'
import { reactive, computed, ref, watch, nextTick, inject, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import LegendProgressBar from './LegendProgressBar.vue'
const { t } = useI18n()
@ -128,6 +143,10 @@ useShadowStyles(`
.lrg-title-caret-slot{grid-column:1;align-self:center;display:flex;align-items:center;justify-content:center;line-height:1;}
.lrg-title{grid-column:2;align-self:center;font-weight:600;font-size:13px;line-height:1.2;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lrg-header-col{align-self:center;text-align:center;white-space:nowrap;font-size:10px;line-height:1.3;color:#888;text-transform:uppercase;letter-spacing:0.3px;font-weight:700;}
/* Prayer progress bar — 2nd implicit row of the title-row subgrid, spanning the
pill columns (2 / -2) so it lines up with the rows beneath. The subgrid has no
row-gap of its own, hence the margin. */
.lrg-progress{grid-column:2 / -2;margin-top:6px;}
/* Data row — subgrid of .lrg-items. Caret lives in col 1 (real grid cell,
not absolute); the pill spans cols 2 to -2 so the trailing column stays
@ -207,11 +226,24 @@ const props = defineProps({
fmtPop: { type: Function, default: v => String(v || 0) },
fmtCount: { type: Function, default: v => String(v || 0) },
/** Disable collapse/expand carets for tree items */
disableCollapse: { type: Boolean, default: false }
disableCollapse: { type: Boolean, default: false },
/**
* Prayer progress payload for the header bar — { covered, total, segments } from
* useLegendData(...).progress. null → no bar (every non-prayer legend). Falls back
* to inject('legendProgress') so a host that only provide()s it (the profile root,
* for the mobile sheet) still gets the bar without threading a prop.
*/
progress: { type: Object, default: null }
})
defineEmits(['filter-click'])
// Progress payload: explicit prop wins, else the provided one (may be a ref/computed).
const injectedProgress = inject('legendProgress', null)
const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null)
// Driver 2026-09-16: the prayer legend's header says "Progress" (never "Legend").
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
// Scroll-to-selected — universal across flat-legend tabs (prayer / engagement /
// adoption / religion), mirroring SemanticTreeLegend's tree-legend scroll. When
// the active filter changes (incl. from a search/geocoder result), bring the

View file

@ -13,10 +13,11 @@
* Tree node shape:
* { id, label, color?, count?, pop?, filter?, info?, children?: TreeNode[] }
*/
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick, inject, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useInstance } from '../../composables/usePplrInstance.js'
import LegendProgressBar from './LegendProgressBar.vue'
const { t, te } = useI18n()
@ -54,12 +55,40 @@ const props = defineProps({
// unchanged; a profile opts in by passing :export-enabled. Gates the
// top-right Export affordance (UX scaffold only — no export yet).
exportEnabled: { type: Boolean, default: false },
// Prayer progress payload for the header bar — { covered, total, segments } from
// useLegendData(...).progress. null → no bar (every non-prayer legend). Falls back
// to inject('legendProgress') so the mobile sheet's embedded legend (LegendMobile →
// this component) gets the bar from the profile's provide() without a prop.
progress: { type: Object, default: null },
})
const emit = defineEmits(['select', 'update:open', 'export'])
const instance = useInstance()
// ── Prayer progress bar (header) ───────────────────────────────────────────────
// Explicit prop wins, else the provided payload (a ref/computed from the profile).
const injectedProgress = inject('legendProgress', null)
const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null)
// Driver 2026-09-16: the prayer legend's header says "Progress" (never "Legend") —
// on the desktop title bar, the mobile sheet title and the collapsed reopen pill.
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
const stlIsDark = computed(() => (instance?.theme.value || 'dark') === 'dark')
// A click on a bar span selects the matching legend row through the SAME handler
// the row uses (selectNode → emit('select') → the profile's filter/camera path).
function findNodeById(nodes, id) {
for (const n of nodes || []) {
if (n?.id === id) return n
const f = n?.children?.length ? findNodeById(n.children, id) : null
if (f) return f
}
return null
}
function selectSegment(key) {
const node = findNodeById(safeNodes.value, key)
if (node) selectNode(node)
}
// ── Tree traversal ─────────────────────────────────────────────────────────────
function treeMaxDepth(nodes, d = 0) {
if (!nodes?.length) return d
@ -513,6 +542,10 @@ useShadowStyles(`
}
.stl-tb-caret-slot { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.stl-tb-caret-slot:empty { display: none; }
/* Prayer progress bar IN the header: the titlebar wraps to a second line that the
bar fills edge to edge (line 1: caret + "Progress" [+ export]; line 2: the bar). */
.stl-titlebar.has-progress { flex-wrap: wrap; row-gap: 7px; }
.stl-progress { flex: 1 1 100%; min-width: 0; }
.stl-tb-title,
.stl-tb-eyebrow {
font: 600 10px ui-monospace, monospace;
@ -954,7 +987,7 @@ useShadowStyles(`
<div class="stl-panel" :class="{ closed: !panelOpen, 'fit-content': hideTabs }" :data-theme="instance?.theme.value || 'dark'">
<div class="stl-inner">
<div class="stl-titlebar">
<div class="stl-titlebar" :class="{ 'has-progress': !!progressData }">
<!-- #title-caret slot — parent (e.g., LegendMobile) injects its own
caret here so it's vertically aligned with the title text via
flex align-items:center. Falls through to PPLR's default
@ -990,7 +1023,7 @@ useShadowStyles(`
</button>
</template>
<template v-else>
<span class="stl-tb-title">{{ title }}</span>
<span class="stl-tb-title">{{ displayTitle }}</span>
</template>
<!-- Export affordance — top-right of the titlebar. Gated by the shared
@ -1037,6 +1070,19 @@ useShadowStyles(`
</div>
</template>
</div>
<!-- Prayer progress bar — header line 2 (prayer legend only). Hover / focus a
span for its popover; click selects the matching row (selectSegment). -->
<LegendProgressBar
v-if="progressData"
class="stl-progress"
:covered="progressData.covered"
:total="progressData.total"
:segments="progressData.segments"
:is-dark="stlIsDark"
:fmt-count="fmtCount"
@select-segment="selectSegment"
/>
</div>
<div v-if="!hideTabs" class="stl-tabs-wrap">
@ -1139,7 +1185,7 @@ useShadowStyles(`
:style="{ background: selectedNode.color }"></span>
{{ selectedNode.label }}
</template>
<template v-else>{{ title }}</template>
<template v-else>{{ displayTitle }}</template>
</span>
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" class="stl-reopen-caret">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"

View file

@ -9,7 +9,8 @@
* so it works identically in both Desktop and Mobile legend shells.
*
* USAGE:
* const { items, columns, title, activeFilter, setFilter, totalCount, totalPopulation }
* const { items, columns, title, activeFilter, setFilter, totalCount, totalPopulation,
* progress } // progress: prayer-only payload for <LegendProgressBar>, else null
* = useLegendData(props.legendType)
*/
@ -409,9 +410,44 @@ export function useLegendData(legendTypeRef, opts = {}) {
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
return {
covered: hasCount + fullCount,
total: totalCount.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
}
}

View file

@ -17,6 +17,12 @@
"hasPrayer": "لديه صلاة",
"fullPrayerCoverage": "تغطية صلاة كاملة"
},
"progress": {
"title": "التقدم",
"covered": "{covered} من {total} مجموعة شعبية مغطاة بالصلاة · {pct}%",
"short": "{covered} من {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "يحتاج إلى انخراط",
"hasEngagement": "لديه انخراط"

View file

@ -17,6 +17,12 @@
"hasPrayer": "Hat Gebet",
"fullPrayerCoverage": "Vollständige Gebetsabdeckung"
},
"progress": {
"title": "Fortschritt",
"covered": "{covered} von {total} Volksgruppen im Gebet abgedeckt · {pct}%",
"short": "{covered} von {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Braucht Engagement",
"hasEngagement": "Hat Engagement"

View file

@ -18,6 +18,12 @@
"hasPrayer": "Has Prayer",
"fullPrayerCoverage": "Full Prayer Coverage"
},
"progress": {
"title": "Progress",
"covered": "{covered} of {total} people groups covered in prayer · {pct}%",
"short": "{covered} of {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Unengaged",
"hasEngagement": "Engaged"

View file

@ -17,6 +17,12 @@
"hasPrayer": "Tiene oración",
"fullPrayerCoverage": "Cobertura de oración completa"
},
"progress": {
"title": "Progreso",
"covered": "{covered} de {total} grupos de personas cubiertos en oración · {pct}%",
"short": "{covered} de {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Necesita implicación",
"hasEngagement": "Tiene implicación"

View file

@ -17,6 +17,12 @@
"hasPrayer": "Prière en cours",
"fullPrayerCoverage": "Couverture de prière complète"
},
"progress": {
"title": "Progression",
"covered": "{covered} sur {total} groupes de personnes couverts par la prière · {pct}%",
"short": "{covered} sur {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Besoin d'engagement",
"hasEngagement": "Engagement en cours"

View file

@ -17,6 +17,12 @@
"hasPrayer": "प्रार्थना है",
"fullPrayerCoverage": "पूर्ण प्रार्थना कवरेज"
},
"progress": {
"title": "प्रगति",
"covered": "{total} में से {covered} जनजाति समूह प्रार्थना में कवर · {pct}%",
"short": "{total} में से {covered} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "सहभागिता चाहिए",
"hasEngagement": "सहभागिता है"

View file

@ -17,6 +17,12 @@
"hasPrayer": "Ha preghiera",
"fullPrayerCoverage": "Copertura di preghiera completa"
},
"progress": {
"title": "Avanzamento",
"covered": "{covered} di {total} gruppi di persone coperti dalla preghiera · {pct}%",
"short": "{covered} di {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Necessita impegno",
"hasEngagement": "Ha impegno"

View file

@ -17,6 +17,12 @@
"hasPrayer": "Tem oração",
"fullPrayerCoverage": "Cobertura de oração completa"
},
"progress": {
"title": "Progresso",
"covered": "{covered} de {total} grupos de pessoas cobertos em oração · {pct}%",
"short": "{covered} de {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Precisa de envolvimento",
"hasEngagement": "Tem envolvimento"

View file

@ -17,6 +17,12 @@
"hasPrayer": "Are rugăciune",
"fullPrayerCoverage": "Acoperire completă de rugăciune"
},
"progress": {
"title": "Progres",
"covered": "{covered} din {total} grupuri de popoare acoperite în rugăciune · {pct}%",
"short": "{covered} din {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Necesită implicare",
"hasEngagement": "Are implicare"

View file

@ -17,6 +17,12 @@
"hasPrayer": "Есть молитва",
"fullPrayerCoverage": "Полное молитвенное покрытие"
},
"progress": {
"title": "Прогресс",
"covered": "{covered} из {total} народных групп охвачены молитвой · {pct}%",
"short": "{covered} из {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "Нуждается в вовлечённости",
"hasEngagement": "Есть вовлечённость"

View file

@ -17,6 +17,12 @@
"hasPrayer": "已有祷告",
"fullPrayerCoverage": "全面祷告覆盖"
},
"progress": {
"title": "进度",
"covered": "{total} 个民族群体中已有 {covered} 个被祷告覆盖 · {pct}%",
"short": "{covered} / {total} · {pct}%",
"segment": "{label} · {count} · {pct}%"
},
"engagement": {
"needsEngagement": "需要参与",
"hasEngagement": "已有参与"