diff --git a/web/src/App.vue b/web/src/App.vue
index b229fa1..f136fc3 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -7,6 +7,7 @@ import HeroHeadline from '@/components/HeroHeadline.vue'
import LegendRow from '@/components/LegendRow.vue'
import PanelGrid from '@/components/PanelGrid.vue'
import PlayerBar from '@/components/PlayerBar.vue'
+import ReachChart from '@/components/ReachChart.vue'
import ResultsTable from '@/components/ResultsTable.vue'
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
import { usePlayback } from '@/composables/usePlayback'
@@ -32,6 +33,7 @@ onMounted(async () => {
+
+import { computed } from 'vue'
+import { useSimStore } from '@/composables/useSimStore'
+import { accentForPanel } from '@/lib/accents'
+import { formatPct } from '@/lib/format'
+import { reachedCountAtRound } from '@/lib/reach'
+
+// Overlaid cumulative reach curves, one per panel in its accent color,
+// with a playhead synced to the global round (spec slice 5). The chart is
+// decorative for screen readers; the ResultsTable carries the numbers.
+
+const store = useSimStore()
+
+const CHART_WIDTH = 640
+const CHART_HEIGHT = 230
+const MARGIN_LEFT = 40
+const MARGIN_RIGHT = 56
+const MARGIN_TOP = 14
+const MARGIN_BOTTOM = 30
+const INNER_WIDTH = CHART_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
+const INNER_HEIGHT = CHART_HEIGHT - MARGIN_TOP - MARGIN_BOTTOM
+
+const finalRound = computed(() => store.maxRounds())
+
+function roundToX(round: number): number {
+ return MARGIN_LEFT + (round / Math.max(finalRound.value, 1)) * INNER_WIDTH
+}
+
+function pctToY(pct: number): number {
+ return MARGIN_TOP + (1 - pct / 100) * INNER_HEIGHT
+}
+
+interface ChartCurve {
+ panelId: string
+ accent: string
+ polylinePoints: string
+ endX: number
+ endY: number
+ endLabel: string
+ playheadY: number
+}
+
+const curves = computed(() =>
+ store.state.panels.flatMap((panel, panelIndex) => {
+ const result = store.state.resultsByPanelId[panel.id]
+ if (!result) return []
+ const numStudents = store.effectiveConfig(panel).numStudents
+ const pctAt = (round: number) =>
+ (reachedCountAtRound(result.reachedAtRound, round) / numStudents) * 100
+ const points: string[] = []
+ for (let round = 0; round <= finalRound.value; round++) {
+ points.push(`${roundToX(round).toFixed(1)},${pctToY(pctAt(round)).toFixed(1)}`)
+ }
+ return [
+ {
+ panelId: panel.id,
+ accent: accentForPanel(panelIndex),
+ polylinePoints: points.join(' '),
+ endX: roundToX(finalRound.value),
+ endY: pctToY(pctAt(finalRound.value)),
+ endLabel: formatPct(result.reachedPct),
+ playheadY: pctToY(pctAt(store.state.round)),
+ },
+ ]
+ }),
+)
+
+const gridLines = computed(() =>
+ [0, 25, 50, 75, 100].map((pct) => ({ pct, y: pctToY(pct) })),
+)
+
+// Label every round while they fit; thin out for long runs.
+const roundLabels = computed(() => {
+ const step = Math.max(1, Math.ceil(finalRound.value / 12))
+ const labels: number[] = []
+ for (let round = 0; round <= finalRound.value; round += step) labels.push(round)
+ return labels
+})
+
+const playheadX = computed(() => roundToX(store.state.round))
+
+
+
+
+ Reach over time
+
+
+
+
+
diff --git a/web/src/components/ScenarioPanel.vue b/web/src/components/ScenarioPanel.vue
index 9f25190..6308752 100644
--- a/web/src/components/ScenarioPanel.vue
+++ b/web/src/components/ScenarioPanel.vue
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import NetworkView from './NetworkView.vue'
import { useSimStore } from '@/composables/useSimStore'
import { roundPct } from '@/lib/format'
+import { reachedCountAtRound } from '@/lib/reach'
import type { PanelSpec } from '@/presets/types'
const props = defineProps<{ panel: PanelSpec; accent: string }>()
@@ -16,10 +17,7 @@ const numStudents = computed(() => store.effectiveConfig(props.panel).numStudent
// shows at the current round, reaching the final outcome on the last one.
const reachedCount = computed(() => {
const panelResult = result.value
- if (!panelResult) return 0
- return panelResult.reachedAtRound.filter(
- (reachedAt) => reachedAt >= 0 && reachedAt <= store.state.round,
- ).length
+ return panelResult ? reachedCountAtRound(panelResult.reachedAtRound, store.state.round) : 0
})
const reachedPctNow = computed(() => (reachedCount.value / numStudents.value) * 100)
const tone = computed(() =>
diff --git a/web/src/lib/reach.ts b/web/src/lib/reach.ts
new file mode 100644
index 0000000..65f34a7
--- /dev/null
+++ b/web/src/lib/reach.ts
@@ -0,0 +1,5 @@
+// Cumulative reach at a playback round, shared by the panel stats and the
+// reach chart so both always agree with the network picture.
+export function reachedCountAtRound(reachedAtRound: number[], round: number): number {
+ return reachedAtRound.filter((reachedAt) => reachedAt >= 0 && reachedAt <= round).length
+}