538 lines
29 KiB
PHP
538 lines
29 KiB
PHP
{{--
|
|
Stakeholder Report — Page 4 (Impact Journey)
|
|
|
|
"Not a tracker — a vision that becomes proof."
|
|
|
|
One artifact, three lens views, tense-driven. Same bars, targets, and
|
|
people stay on screen; the framing and tense change around them.
|
|
|
|
Vision (Day 1, always available) — targets only, "will"
|
|
Progress (unlocks with any snapshot) — captured + remaining, "is becoming"
|
|
Impact (unlocks when targets met) — achieved, "did"
|
|
|
|
Bookends both authored, both present Day 1:
|
|
STARTED (the world / the problem) → the journey → DIFFERENT (the impact)
|
|
|
|
Human meaning is AUTHORED on the canvas (item.description / item.assumptions
|
|
/ item.conclusion), never generated — §7 rule 12. A concatenated narrative
|
|
is a fabrication when this ends up in a funder's hands.
|
|
|
|
Per-lens content is rendered server-side and CSS-toggled by the parent
|
|
wrapper's data-active-lens attribute — so the "same component, words &
|
|
fill change" reads as one journey maturing, not hard cuts.
|
|
|
|
Vars in:
|
|
$logicModel null | {narrative, coverageMatrix, projectLinks, linkedGoals, ...}
|
|
$hasLM bool
|
|
$scope 'strategy' | 'program'
|
|
--}}
|
|
|
|
|
|
@if (! $hasLM)
|
|
<div class="p4-wrap">
|
|
<div class="p4-empty">
|
|
<div class="lb">{{ __('stakeholder.ij.no_lm_title') }}</div>
|
|
<div class="s">{{ __('stakeholder.ij.no_lm_hint') }}</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
@php
|
|
$stages = $logicModel['coverageMatrix']['stages'] ?? [];
|
|
$projectLinks = $logicModel['projectLinks'] ?? [];
|
|
$linkedGoals = $logicModel['linkedGoals'] ?? [];
|
|
|
|
$outputItems = $stages['outputs']['items'] ?? [];
|
|
$outcomeItems = $stages['outcomes']['items'] ?? [];
|
|
$impactItems = $stages['impact']['items'] ?? [];
|
|
|
|
// Metric aggregator (unchanged from before; comment retained for context).
|
|
$metricFor = function ($item) use ($projectLinks, $linkedGoals) {
|
|
$itemId = (int) (((array) $item)['id'] ?? 0);
|
|
$links = $projectLinks[$itemId] ?? [];
|
|
$goals = [];
|
|
foreach ($links as $link) {
|
|
if (($link['linked_entity_type'] ?? '') !== 'goal') continue;
|
|
$gid = (int) ($link['linked_entity_id'] ?? 0);
|
|
if (isset($linkedGoals[$gid])) $goals[] = $linkedGoals[$gid];
|
|
}
|
|
if (count($goals) === 0) return null;
|
|
|
|
$current = array_sum(array_column($goals, 'currentValue'));
|
|
$target = array_sum(array_column($goals, 'endValue'));
|
|
$unit = $goals[0]['metricType'] ?? 'number';
|
|
|
|
$byDate = [];
|
|
foreach ($goals as $g) {
|
|
foreach (($g['snapshots'] ?? []) as $s) {
|
|
$day = substr((string) $s['date'], 0, 10);
|
|
if (! isset($byDate[$day])) $byDate[$day] = 0.0;
|
|
$byDate[$day] += (float) $s['value'];
|
|
}
|
|
}
|
|
ksort($byDate);
|
|
$snapshots = [];
|
|
foreach ($byDate as $day => $value) $snapshots[] = ['date' => $day, 'value' => $value];
|
|
|
|
$arr = (array) $item;
|
|
// Meaning source per §1 of the authored-meaning spec:
|
|
// why_this_matters — the primary source (authored, nullable)
|
|
// No fallback to conclusion — conclusion narrows to "as measured
|
|
// by" methodology only, going forward. Mining it for meaning is
|
|
// exactly the double-duty that broke Page 2 earlier.
|
|
return [
|
|
'id' => $itemId,
|
|
'label' => trim((string) ($arr['description'] ?? '')),
|
|
'meaning' => trim((string) ($arr['why_this_matters'] ?? '')),
|
|
'measuredBy' => trim((string) ($arr['conclusion'] ?? '')),
|
|
'current' => $current,
|
|
'target' => $target,
|
|
'unit' => $unit,
|
|
'snapshots' => $snapshots,
|
|
];
|
|
};
|
|
|
|
$anySnapshots = false;
|
|
$collectMetrics = function (array $items) use ($metricFor, &$anySnapshots) {
|
|
$metrics = [];
|
|
foreach ($items as $it) {
|
|
$m = $metricFor($it);
|
|
if ($m === null) continue;
|
|
if (count($m['snapshots']) > 0) $anySnapshots = true;
|
|
$metrics[] = $m;
|
|
}
|
|
return $metrics;
|
|
};
|
|
$outMetrics = $collectMetrics($outputItems);
|
|
$ocMetrics = $collectMetrics($outcomeItems);
|
|
|
|
$anyHit = false;
|
|
foreach (array_merge($outMetrics, $ocMetrics) as $m) {
|
|
if ($m['target'] > 0 && $m['current'] >= $m['target']) { $anyHit = true; break; }
|
|
}
|
|
$progressUnlocked = $anySnapshots;
|
|
$impactUnlocked = $anyHit;
|
|
|
|
// Default lens = the most advanced state the data supports. A page
|
|
// with snapshots defaults to Progress (not Vision) so a returning
|
|
// reader lands on the current reality — Vision is reachable via
|
|
// the toggle for the "share the promise" flow.
|
|
$defaultLens = $impactUnlocked ? 'impact' : ($progressUnlocked ? 'progress' : 'vision');
|
|
|
|
$fmt = function ($value, string $unit) {
|
|
$value = (float) $value;
|
|
if ($unit === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%';
|
|
if ($value == floor($value)) return number_format($value, 0, '.', ',');
|
|
return number_format($value, 1, '.', ',');
|
|
};
|
|
|
|
// Detects whether the authored label starts with the target number
|
|
// (e.g. "1,200 screenings completed" for target 1,200). Used to
|
|
// suppress the redundant "target N" subtitle on Vision — the label
|
|
// is already the promise.
|
|
$labelContainsTarget = function (string $label, float $target, string $unit) use ($fmt): bool {
|
|
if ($target <= 0) return false;
|
|
$formatted = $fmt($target, $unit);
|
|
// strip commas for a looser match too
|
|
$stripped = str_replace(',', '', $formatted);
|
|
$labelStripped = str_replace(',', '', $label);
|
|
return stripos($labelStripped, $stripped) !== false;
|
|
};
|
|
|
|
// ── STARTED bookend text.
|
|
// Authored ONLY. Sourced from the Impact item's `starting_picture`
|
|
// (a dedicated field for the world today, before this work). No
|
|
// synthesis, no fallback to narrative concatenation — an empty state
|
|
// is more honest than a fabrication, and this artifact ends up in
|
|
// funders' hands.
|
|
$startedText = '';
|
|
if (count($impactItems) > 0) {
|
|
$startedText = trim((string) (((array) $impactItems[0])['starting_picture'] ?? ''));
|
|
}
|
|
|
|
// ── DIFFERENT bookend: authored impact title + authored meaning.
|
|
// Meaning source: why_this_matters ONLY. No fallback to conclusion
|
|
// (which is now "as measured by" methodology). No fallback to
|
|
// assumptions (that's for the theory-of-change assertion, a
|
|
// different concept from the funder-facing meaning).
|
|
$differentTitle = '';
|
|
$differentMeaning = '';
|
|
if (count($impactItems) > 0) {
|
|
$impArr = (array) $impactItems[0];
|
|
$differentTitle = trim((string) ($impArr['description'] ?? ''));
|
|
$differentMeaning = trim((string) ($impArr['why_this_matters'] ?? ''));
|
|
}
|
|
|
|
// ── Arc statement (Vision beat 2). The ONE place a light
|
|
// concatenation is correct: assembled from authored labels, capped
|
|
// at 3 producing + 2 achieving, two sentences maximum. Never the
|
|
// §8 rule-1 canvas dump. Nothing generated — every word is a label
|
|
// a human wrote on the canvas.
|
|
$capProducing = array_slice($outMetrics, 0, 3);
|
|
$capAchieving = array_slice($ocMetrics, 0, 2);
|
|
$producingLabels = array_values(array_filter(array_map(
|
|
static fn ($m) => trim((string) $m['label']),
|
|
$capProducing
|
|
)));
|
|
$achievingLabels = array_values(array_filter(array_map(
|
|
static fn ($m) => trim((string) $m['label']),
|
|
$capAchieving
|
|
)));
|
|
|
|
$producingSentence = $producingLabels === []
|
|
? ''
|
|
: implode('. ', $producingLabels).'.';
|
|
|
|
$achievingSentence = '';
|
|
if (count($achievingLabels) === 1) {
|
|
$achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $achievingLabels[0]).'.';
|
|
} elseif (count($achievingLabels) >= 2) {
|
|
// Natural join: "X and Y" for two, "X, Y, and Z" for three (only
|
|
// if the cap is ever raised).
|
|
if (count($achievingLabels) === 2) {
|
|
$joined = $achievingLabels[0].' '.__('stakeholder.ij.beat_arc_and').' '.$achievingLabels[1];
|
|
} else {
|
|
$joined = implode(', ', array_slice($achievingLabels, 0, -1))
|
|
.', '.__('stakeholder.ij.beat_arc_and').' '.end($achievingLabels);
|
|
}
|
|
$achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $joined).'.';
|
|
}
|
|
|
|
$arcStatement = trim($producingSentence.' '.$achievingSentence);
|
|
|
|
// Only the "Logic Model canvas" phrase links out — the surrounding nudge
|
|
// sentence stays plain text. Built once, injected via sprintf %s below.
|
|
$lmCanvasLink = '<a href="'.BASE_URL.'/logicmodelcanvas/showCanvas">'.e(__('stakeholder.ij.nudge_link')).'</a>';
|
|
@endphp
|
|
|
|
<div class="p4-wrap" data-active-lens="{{ $defaultLens }}" data-p4-lens-wrap>
|
|
|
|
{{-- Header --}}
|
|
<div class="p4-hd">
|
|
<div>
|
|
<div class="t">{{ __('stakeholder.ij.header_title') }}</div>
|
|
<div class="s">{{ __('stakeholder.ij.header_sub') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Lens toggle — shows only when >=2 lenses exist for the reader --}}
|
|
@if ($progressUnlocked || $impactUnlocked)
|
|
<div class="p4-lens" data-p4-lens>
|
|
<button type="button" class="lopt @if ($defaultLens === 'vision') is-active @endif" data-lens-target="vision">{{ __('stakeholder.ij.lens_vision') }}</button>
|
|
<button type="button" class="lopt @if ($defaultLens === 'progress') is-active @endif @if (! $progressUnlocked) locked @endif" data-lens-target="progress" @if (! $progressUnlocked) disabled @endif>
|
|
@if (! $progressUnlocked)<i class="fa fa-lock"></i>@endif
|
|
{{ __('stakeholder.ij.lens_progress') }}
|
|
</button>
|
|
<button type="button" class="lopt @if ($defaultLens === 'impact') is-active @endif @if (! $impactUnlocked) locked @endif" data-lens-target="impact" @if (! $impactUnlocked) disabled @endif>
|
|
@if (! $impactUnlocked)<i class="fa fa-lock"></i>@endif
|
|
{{ __('stakeholder.ij.lens_impact') }}
|
|
</button>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ── VISION lens: three beats. Complete on day one, by design.
|
|
Beat 1 (world today) and beat 3 (world delivered) carry the
|
|
same authored text as the STARTED/DIFFERENT bookends below
|
|
— the block-level lens toggle means only one shows at a
|
|
time, so there is no duplication for the reader. --}}
|
|
<div class="p4-vision" data-lens-block="vision">
|
|
<div class="beat today">
|
|
<div class="lb"><i class="fa fa-flag"></i> {{ __('stakeholder.ij.beat_today_lb') }}</div>
|
|
@if ($startedText !== '')
|
|
<div class="txt">{{ $startedText }}</div>
|
|
@else
|
|
<div class="empty">
|
|
{{ __('stakeholder.ij.bookend_started_empty') }}
|
|
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="beat arc">
|
|
<div class="lb"><i class="fa fa-arrow-trend-up"></i> {{ __('stakeholder.ij.beat_arc_lb') }}</div>
|
|
@if ($arcStatement !== '')
|
|
<div class="statement">{{ $arcStatement }}</div>
|
|
@else
|
|
<div class="empty">
|
|
{{ __('stakeholder.ij.beat_arc_empty') }}
|
|
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.beat_arc_empty_nudge')), $lmCanvasLink) !!}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="beat delivered">
|
|
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.ij.beat_delivered_lb') }}</div>
|
|
@if ($differentTitle !== '')
|
|
<div class="txt">{{ $differentTitle }}</div>
|
|
@if ($differentMeaning !== '')
|
|
<div class="meaning">{{ $differentMeaning }}</div>
|
|
@endif
|
|
@else
|
|
<div class="empty">
|
|
{{ __('stakeholder.ij.bookend_different_empty') }}
|
|
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── PROGRESS + IMPACT: the same bookends flanking the arc with
|
|
live tracks. Hidden entirely on the Vision lens by the
|
|
block-level toggle above. --}}
|
|
<div data-lens-block="tracks">
|
|
|
|
{{-- STARTED bookend — authored problem text or honest empty state.
|
|
NO narrative dump. NO generated summary. --}}
|
|
<div class="p4-bookend started">
|
|
<div class="lb"><i class="fa fa-flag"></i> {{ __('stakeholder.ij.bookend_started') }}</div>
|
|
@if ($startedText !== '')
|
|
<div class="meaning" style="margin-top:0;">{{ $startedText }}</div>
|
|
@else
|
|
<div class="empty">
|
|
{{ __('stakeholder.ij.bookend_started_empty') }}
|
|
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- The Arc --}}
|
|
@if (count($outMetrics) > 0 || count($ocMetrics) > 0)
|
|
<div class="p4-arc">
|
|
<div class="lb"><i class="fa fa-arrow-trend-up"></i> {{ __('stakeholder.ij.arc_label') }}</div>
|
|
|
|
@foreach ([
|
|
['key'=>'outputs', 'items'=>$outMetrics, 'title'=>__('stakeholder.ij.arc_producing'), 'icon'=>'fa-boxes-stacked'],
|
|
['key'=>'outcomes', 'items'=>$ocMetrics, 'title'=>__('stakeholder.ij.arc_achieving'), 'icon'=>'fa-chart-line'],
|
|
] as $group)
|
|
@if (count($group['items']) === 0) @continue @endif
|
|
<div class="p4-mgroup {{ $group['key'] }}">
|
|
<div class="gh"><i class="fa {{ $group['icon'] }}"></i> {{ $group['title'] }}</div>
|
|
|
|
@foreach ($group['items'] as $m)
|
|
@php
|
|
$n = count($m['snapshots']);
|
|
$growthState = $n <= 1 ? 'baseline' : ($n === 2 ? 'before_after' : 'full_arc');
|
|
$hitTarget = $m['target'] > 0 && $m['current'] >= $m['target'];
|
|
$peak = max($m['target'], $m['current'], 1);
|
|
foreach ($m['snapshots'] as $s) $peak = max($peak, (float) $s['value']);
|
|
$barH = fn ($v) => max(4, min(50, (int) round(($v / $peak) * 50)));
|
|
$labelHasTarget = $labelContainsTarget($m['label'], $m['target'], $m['unit']);
|
|
@endphp
|
|
<div class="p4-metric">
|
|
<div class="mn">
|
|
@if ($m['meaning'] !== '')
|
|
{{-- Meaning leads. The metric is evidence.
|
|
"as measured by {label}" reads as the receipt
|
|
underneath — same rule as Page 2's verdict + read
|
|
pattern, one level down. --}}
|
|
{{ $m['meaning'] }}
|
|
<span class="meaning">
|
|
{{ __('stakeholder.ij.as_measured_by') }} {{ $m['label'] }}
|
|
@if ($m['measuredBy'] !== '')
|
|
— {{ $m['measuredBy'] }}
|
|
@endif
|
|
</span>
|
|
@else
|
|
{{-- No authored meaning yet: today's behavior — the
|
|
authored label leads, optional methodology below.
|
|
No regression. --}}
|
|
{{ $m['label'] }}
|
|
@if (! $labelHasTarget && $m['target'] > 0)
|
|
<span class="tgt">{{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }}</span>
|
|
@endif
|
|
@if ($m['measuredBy'] !== '')
|
|
<span class="meaning">{{ __('stakeholder.ij.as_measured_by') }} {{ $m['measuredBy'] }}</span>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
|
|
{{-- ONE persistent bar per metric. Track = the target
|
|
(always visible, same shape all lenses). Fill = current
|
|
progress, animates on lens change via CSS transition.
|
|
Vision → fill 0%. Progress → fill current/target. Impact
|
|
→ fill 100%. The morph IS the story. --}}
|
|
@php
|
|
// Cap the visual fill % — over-target still reads as full,
|
|
// "we did this" carries the over-delivery in the value label.
|
|
$progressPct = $m['target'] > 0
|
|
? min(100, ($m['current'] / $m['target']) * 100)
|
|
: 0;
|
|
// Fill values per lens — the JS uses these dataset attrs
|
|
// to swap width + label text on lens change.
|
|
$visionFill = 0;
|
|
$progressFill = $progressPct;
|
|
$impactFill = 100;
|
|
$visionLabel = __('stakeholder.ij.fill_lbl_vision');
|
|
$progressLabel = sprintf(__('stakeholder.ij.fill_lbl_progress'), $fmt($m['current'], $m['unit']));
|
|
$impactLabel = sprintf(__('stakeholder.ij.fill_lbl_impact'), $fmt(max($m['current'], $m['target']), $m['unit']));
|
|
@endphp
|
|
<div class="p4-arc-viz"
|
|
data-p4-bar
|
|
data-vision-fill="{{ $visionFill }}"
|
|
data-progress-fill="{{ $progressFill }}"
|
|
data-impact-fill="{{ $impactFill }}"
|
|
data-vision-lbl="{{ $visionLabel }}"
|
|
data-progress-lbl="{{ $progressLabel }}"
|
|
data-impact-lbl="{{ $impactLabel }}">
|
|
<div class="p4-scale">
|
|
<span class="p4-fill-lbl">{{
|
|
$defaultLens === 'vision' ? $visionLabel
|
|
: ($defaultLens === 'impact' ? $impactLabel : $progressLabel)
|
|
}}</span>
|
|
<span class="p4-target-lbl">{{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }}</span>
|
|
</div>
|
|
<div class="p4-track">
|
|
<div class="p4-fill" style="width:{{
|
|
$defaultLens === 'vision' ? $visionFill
|
|
: ($defaultLens === 'impact' ? $impactFill : $progressFill)
|
|
}}%;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ─── VERDICTS — tense-aware, encouragement via structure ── --}}
|
|
{{-- Vision: future tense. --}}
|
|
<span class="p4-verdict willbe" data-lens="vision">
|
|
<span class="sd"></span> {{ __('stakeholder.ij.v_will_be_measured') }}
|
|
</span>
|
|
{{-- Progress: present-becoming tense. One framing for both
|
|
units — "N% of the way" reads truthfully for counts and
|
|
percents alike, and stays consistent down the column
|
|
regardless of metric type. --}}
|
|
{{-- One progress color for every in-progress row: the text is
|
|
uniformly "N% of the way", and the percentage itself conveys
|
|
how far along. A silent amber↔blue flip at a hidden 75%
|
|
threshold made identically-worded rows look different for no
|
|
visible reason, so the dot stays consistent down the column. --}}
|
|
<span class="p4-verdict @if ($hitTarget) hit @elseif ($n === 0) captured @else trending @endif" data-lens="progress">
|
|
<span class="sd"></span>
|
|
@if ($n === 0)
|
|
{{ __('stakeholder.ij.v_no_snapshots_yet') }}
|
|
@elseif ($hitTarget)
|
|
{{ __('stakeholder.ij.v_hit_target_progress') }}
|
|
@elseif ($m['target'] > 0)
|
|
{{ sprintf(__('stakeholder.ij.v_pct_of_way'), (int) round(($m['current'] / $m['target']) * 100)) }}
|
|
@else
|
|
{{ __('stakeholder.ij.v_trending') }}
|
|
@endif
|
|
</span>
|
|
{{-- Impact: past tense. Only truthful for hit-target rows. --}}
|
|
<span class="p4-verdict @if ($hitTarget) hit @else captured @endif" data-lens="impact">
|
|
<span class="sd"></span>
|
|
@if ($hitTarget)
|
|
{{ __('stakeholder.ij.v_we_did_this') }}
|
|
@else
|
|
{{ __('stakeholder.ij.v_not_yet_impact') }}
|
|
@endif
|
|
</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endforeach
|
|
|
|
{{-- Structural encouragement — only when the claim can be
|
|
reconstructed from what's on screen (§7 rule 8 / v6 Fix 2).
|
|
Progress-lens "one more completes the arc" needed a period
|
|
cadence the page doesn't render yet; dropped until we do. --}}
|
|
@if (! $progressUnlocked)
|
|
<div class="p4-chip" data-lens="vision"><i class="fa fa-circle-info"></i> {{ __('stakeholder.ij.chip_first_period') }}</div>
|
|
@endif
|
|
</div>
|
|
@else
|
|
<div class="p4-empty">
|
|
<div class="lb">{{ __('stakeholder.ij.no_metrics_title') }}</div>
|
|
<div class="s">{{ __('stakeholder.ij.no_metrics_hint') }}</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- DIFFERENT bookend — authored impact + supporting meaning.
|
|
Meaning is the emotional payload: WHY this matters to the
|
|
people it affects. Never generated. --}}
|
|
<div class="p4-bookend different">
|
|
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.ij.bookend_different') }}</div>
|
|
@if ($differentTitle !== '')
|
|
<div class="txt">{{ $differentTitle }}</div>
|
|
@if ($differentMeaning !== '')
|
|
<div class="meaning">{{ $differentMeaning }}</div>
|
|
@endif
|
|
{{-- Tense hints — one per lens, framing shifts with capture state. --}}
|
|
<span class="tense-hint" data-lens="vision">{{ __('stakeholder.ij.different_tense_vision') }}</span>
|
|
<span class="tense-hint" data-lens="progress">{{ __('stakeholder.ij.different_tense_progress') }}</span>
|
|
<span class="tense-hint" data-lens="impact">{{ __('stakeholder.ij.different_tense_impact') }}</span>
|
|
@else
|
|
<div class="empty">
|
|
{{ __('stakeholder.ij.bookend_different_empty') }}
|
|
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
</div>{{-- /[data-lens-block=tracks] --}}
|
|
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
var wrap = document.querySelector('[data-p4-lens-wrap]');
|
|
var toggle = document.querySelector('[data-p4-lens]');
|
|
if (! wrap || ! toggle) return;
|
|
|
|
var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
// Morph a single bar to the target lens: swap fill width + label text.
|
|
// Width transition is CSS-driven; label crossfade is a quick opacity dip.
|
|
function morphBar (viz, lens) {
|
|
var fillEl = viz.querySelector('.p4-fill');
|
|
var lblEl = viz.querySelector('.p4-fill-lbl');
|
|
if (! fillEl || ! lblEl) return;
|
|
var pct = viz.getAttribute('data-' + lens + '-fill');
|
|
var lbl = viz.getAttribute('data-' + lens + '-lbl');
|
|
if (pct !== null) fillEl.style.width = pct + '%';
|
|
if (lbl !== null) {
|
|
if (reducedMotion) {
|
|
lblEl.textContent = lbl;
|
|
} else {
|
|
lblEl.style.opacity = '0';
|
|
setTimeout(function () { lblEl.textContent = lbl; lblEl.style.opacity = '1'; }, 140);
|
|
}
|
|
}
|
|
}
|
|
|
|
function switchLens (lens) {
|
|
var wasVision = wrap.getAttribute('data-active-lens') === 'vision';
|
|
wrap.setAttribute('data-active-lens', lens);
|
|
|
|
// Arrival — when leaving Vision for Progress/Impact, the tracks
|
|
// don't just appear (that would be a jump); they stagger in.
|
|
// Rows start hidden (.arriving), then .arrived is added on a
|
|
// stagger so the CSS transition plays. Reduced-motion → instant.
|
|
if (wasVision && lens !== 'vision' && ! reducedMotion) {
|
|
var rows = wrap.querySelectorAll('[data-lens-block="tracks"] .p4-metric');
|
|
rows.forEach(function (row) { row.classList.remove('arrived'); row.classList.add('arriving'); });
|
|
rows.forEach(function (row, i) {
|
|
setTimeout(function () {
|
|
row.classList.remove('arriving');
|
|
row.classList.add('arrived');
|
|
}, i * 80);
|
|
});
|
|
}
|
|
|
|
var bars = wrap.querySelectorAll('[data-p4-bar]');
|
|
bars.forEach(function (viz, i) {
|
|
// Stagger by 80ms so the morph reads as a sequence, not a jump.
|
|
var delay = reducedMotion ? 0 : (i * 80);
|
|
if (delay === 0) morphBar(viz, lens);
|
|
else setTimeout(function () { morphBar(viz, lens); }, delay);
|
|
});
|
|
}
|
|
|
|
toggle.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.lopt');
|
|
if (! btn || btn.classList.contains('locked') || btn.hasAttribute('disabled')) return;
|
|
var target = btn.getAttribute('data-lens-target');
|
|
if (! target) return;
|
|
toggle.querySelectorAll('.lopt').forEach(function (b) { b.classList.toggle('is-active', b === btn); });
|
|
switchLens(target);
|
|
});
|
|
})();
|
|
</script>
|
|
@endif
|