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)) + + + + + 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 +}