web: reach-over-time chart with playhead, slice 5 of M3
Hand-rolled SVG as decided: dashed percent grid, one cumulative curve per panel in its accent color, end labels via the shared formatPct, and a playhead line with per-curve dots synced to the global round. The cumulative-at-round counting moved into lib/reach.ts so the panel stats and the chart cannot disagree.
This commit is contained in:
parent
e9d57b2082
commit
da1d86aba7
4 changed files with 191 additions and 4 deletions
|
|
@ -7,6 +7,7 @@ import HeroHeadline from '@/components/HeroHeadline.vue'
|
||||||
import LegendRow from '@/components/LegendRow.vue'
|
import LegendRow from '@/components/LegendRow.vue'
|
||||||
import PanelGrid from '@/components/PanelGrid.vue'
|
import PanelGrid from '@/components/PanelGrid.vue'
|
||||||
import PlayerBar from '@/components/PlayerBar.vue'
|
import PlayerBar from '@/components/PlayerBar.vue'
|
||||||
|
import ReachChart from '@/components/ReachChart.vue'
|
||||||
import ResultsTable from '@/components/ResultsTable.vue'
|
import ResultsTable from '@/components/ResultsTable.vue'
|
||||||
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
|
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
|
||||||
import { usePlayback } from '@/composables/usePlayback'
|
import { usePlayback } from '@/composables/usePlayback'
|
||||||
|
|
@ -32,6 +33,7 @@ onMounted(async () => {
|
||||||
<PanelGrid />
|
<PanelGrid />
|
||||||
<LegendRow />
|
<LegendRow />
|
||||||
<PlayerBar />
|
<PlayerBar />
|
||||||
|
<ReachChart />
|
||||||
<ResultsTable
|
<ResultsTable
|
||||||
:panels="store.state.panels"
|
:panels="store.state.panels"
|
||||||
:results-by-panel-id="store.state.resultsByPanelId"
|
:results-by-panel-id="store.state.resultsByPanelId"
|
||||||
|
|
|
||||||
182
web/src/components/ReachChart.vue
Normal file
182
web/src/components/ReachChart.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
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<ChartCurve[]>(() =>
|
||||||
|
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))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-if="finalRound > 0" class="card chart" aria-label="Reach over time">
|
||||||
|
<h3>Reach over time</h3>
|
||||||
|
<svg :viewBox="`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`" aria-hidden="true">
|
||||||
|
<line
|
||||||
|
v-for="grid in gridLines"
|
||||||
|
:key="grid.pct"
|
||||||
|
:x1="MARGIN_LEFT"
|
||||||
|
:y1="grid.y"
|
||||||
|
:x2="CHART_WIDTH - MARGIN_RIGHT"
|
||||||
|
:y2="grid.y"
|
||||||
|
stroke="var(--border-soft)"
|
||||||
|
stroke-width="1"
|
||||||
|
:stroke-dasharray="grid.pct > 0 ? '2 4' : undefined"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
v-for="grid in gridLines"
|
||||||
|
:key="`label-${grid.pct}`"
|
||||||
|
:x="MARGIN_LEFT - 9"
|
||||||
|
:y="grid.y + 3.5"
|
||||||
|
text-anchor="end"
|
||||||
|
font-size="11"
|
||||||
|
fill="var(--ink-4)"
|
||||||
|
>
|
||||||
|
{{ grid.pct }}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
v-for="round in roundLabels"
|
||||||
|
:key="`round-${round}`"
|
||||||
|
:x="roundToX(round)"
|
||||||
|
:y="CHART_HEIGHT - 8"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="11"
|
||||||
|
fill="var(--ink-4)"
|
||||||
|
>
|
||||||
|
{{ round }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<line
|
||||||
|
:x1="playheadX"
|
||||||
|
:y1="MARGIN_TOP"
|
||||||
|
:x2="playheadX"
|
||||||
|
:y2="CHART_HEIGHT - MARGIN_BOTTOM"
|
||||||
|
stroke="var(--ink-4)"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.55"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g v-for="curve in curves" :key="curve.panelId">
|
||||||
|
<polyline
|
||||||
|
:points="curve.polylinePoints"
|
||||||
|
fill="none"
|
||||||
|
:stroke="curve.accent"
|
||||||
|
stroke-width="2.4"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<circle :cx="playheadX" :cy="curve.playheadY" r="3.5" :fill="curve.accent" />
|
||||||
|
<text
|
||||||
|
:x="curve.endX + 9"
|
||||||
|
:y="curve.endY + 4"
|
||||||
|
font-size="12"
|
||||||
|
font-weight="650"
|
||||||
|
:fill="curve.accent"
|
||||||
|
>
|
||||||
|
{{ curve.endLabel }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg text {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -3,6 +3,7 @@ import { computed } from 'vue'
|
||||||
import NetworkView from './NetworkView.vue'
|
import NetworkView from './NetworkView.vue'
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
import { roundPct } from '@/lib/format'
|
import { roundPct } from '@/lib/format'
|
||||||
|
import { reachedCountAtRound } from '@/lib/reach'
|
||||||
import type { PanelSpec } from '@/presets/types'
|
import type { PanelSpec } from '@/presets/types'
|
||||||
|
|
||||||
const props = defineProps<{ panel: PanelSpec; accent: string }>()
|
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.
|
// shows at the current round, reaching the final outcome on the last one.
|
||||||
const reachedCount = computed(() => {
|
const reachedCount = computed(() => {
|
||||||
const panelResult = result.value
|
const panelResult = result.value
|
||||||
if (!panelResult) return 0
|
return panelResult ? reachedCountAtRound(panelResult.reachedAtRound, store.state.round) : 0
|
||||||
return panelResult.reachedAtRound.filter(
|
|
||||||
(reachedAt) => reachedAt >= 0 && reachedAt <= store.state.round,
|
|
||||||
).length
|
|
||||||
})
|
})
|
||||||
const reachedPctNow = computed(() => (reachedCount.value / numStudents.value) * 100)
|
const reachedPctNow = computed(() => (reachedCount.value / numStudents.value) * 100)
|
||||||
const tone = computed(() =>
|
const tone = computed(() =>
|
||||||
|
|
|
||||||
5
web/src/lib/reach.ts
Normal file
5
web/src/lib/reach.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue