web: playback, slice 4 of M3

usePlayback drives one global round on a rAF timer: 700 ms per round at
1x, speed pill cycling 0.5/1/2, play-once autoplay after the first load,
no looping; at the final round the play button becomes replay. Under
prefers-reduced-motion there is no autoplay, the page settles on the
final round, and the cadence slows to 1000 ms.

The PlayerBar is the mockup's centered pill with a native range
scrubber (one tick per round, aria-valuetext, seeking pauses) and the
global keyboard map: Space toggles, arrows step, Home/End jump; text
inputs and native control handling are left alone. A single polite live
region announces run updates, pauses, scrub releases, and the final
state.

Nodes reached in the current round pop in (scale 0.6 to 1, 250 ms
ease-out) with a small deterministic stagger so each round reads as a
wave. Per Justin's feedback the panel percentage and reached count now
follow the playhead instead of sitting on the final outcome; the hero
keeps the final numbers, since its sentence claims outcomes.
This commit is contained in:
Justin Visser 2026-06-10 17:15:38 +02:00
parent 71baf10e73
commit 0e3a14a0b3
7 changed files with 736 additions and 13 deletions

View file

@ -6,16 +6,20 @@ import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
import HeroHeadline from '@/components/HeroHeadline.vue' 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 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 { useSimStore } from '@/composables/useSimStore' import { useSimStore } from '@/composables/useSimStore'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
const store = useSimStore() const store = useSimStore()
const playback = usePlayback()
useTheme() // resolve and apply the theme before first paint of the app useTheme() // resolve and apply the theme before first paint of the app
onMounted(() => { onMounted(async () => {
void store.initialize() await store.initialize()
playback.autoplayOnce()
}) })
</script> </script>
@ -27,6 +31,7 @@ onMounted(() => {
<ErrorBanner /> <ErrorBanner />
<PanelGrid /> <PanelGrid />
<LegendRow /> <LegendRow />
<PlayerBar />
<ResultsTable <ResultsTable
:panels="store.state.panels" :panels="store.state.panels"
:results-by-panel-id="store.state.resultsByPanelId" :results-by-panel-id="store.state.resultsByPanelId"
@ -34,6 +39,7 @@ onMounted(() => {
/> />
</main> </main>
<FooterDisclaimer /> <FooterDisclaimer />
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
</template> </template>
<style scoped> <style scoped>

View file

@ -23,6 +23,7 @@ interface RenderedNode {
x: number x: number
y: number y: number
reached: boolean reached: boolean
justReached: boolean // reached exactly this round: animates in (spec 5.4)
educated: boolean educated: boolean
isOrigin: boolean isOrigin: boolean
} }
@ -33,10 +34,12 @@ const nodes = computed<RenderedNode[]>(() => {
const educatedNodes = new Set(panelResult.educated) const educatedNodes = new Set(panelResult.educated)
return layout.value.map((point, nodeIndex) => { return layout.value.map((point, nodeIndex) => {
const reachedAtRound = panelResult.reachedAtRound[nodeIndex] ?? -1 const reachedAtRound = panelResult.reachedAtRound[nodeIndex] ?? -1
const reached = reachedAtRound >= 0 && reachedAtRound <= store.state.round
return { return {
x: point.x, x: point.x,
y: point.y, y: point.y,
reached: reachedAtRound >= 0 && reachedAtRound <= store.state.round, reached,
justReached: reached && reachedAtRound === store.state.round && store.state.round > 0,
educated: educatedNodes.has(nodeIndex), educated: educatedNodes.has(nodeIndex),
isOrigin: nodeIndex === effectiveConfig.value.origin, isOrigin: nodeIndex === effectiveConfig.value.origin,
} }
@ -46,6 +49,13 @@ const nodes = computed<RenderedNode[]>(() => {
function edgeEnd(edge: number[], side: 0 | 1) { function edgeEnd(edge: number[], side: 0 | 1) {
return layout.value[edge[side] ?? 0] ?? { x: 0, y: 0 } return layout.value[edge[side] ?? 0] ?? { x: 0, y: 0 }
} }
// A small deterministic stagger inside the round so the pops read as a
// wave instead of one synchronized blink. Stays well inside the 700 ms
// round cadence (250 ms pop + at most 150 ms delay).
function popDelay(nodeIndex: number): string {
return `${(nodeIndex * 37) % 150}ms`
}
</script> </script>
<template> <template>
@ -75,6 +85,8 @@ function edgeEnd(edge: number[], side: 0 | 1) {
</template> </template>
<circle <circle
v-else-if="node.educated" v-else-if="node.educated"
:class="{ pop: node.justReached }"
:style="node.justReached ? { animationDelay: popDelay(nodeIndex) } : undefined"
:cx="node.x" :cx="node.x"
:cy="node.y" :cy="node.y"
r="3.8" r="3.8"
@ -82,7 +94,15 @@ function edgeEnd(edge: number[], side: 0 | 1) {
stroke="var(--edu)" stroke="var(--edu)"
stroke-width="2.2" stroke-width="2.2"
/> />
<circle v-else-if="node.reached" :cx="node.x" :cy="node.y" r="4" fill="var(--spread)" /> <circle
v-else-if="node.reached"
:class="{ pop: node.justReached }"
:style="node.justReached ? { animationDelay: popDelay(nodeIndex) } : undefined"
:cx="node.x"
:cy="node.y"
r="4"
fill="var(--spread)"
/>
<circle v-else :cx="node.x" :cy="node.y" r="3" fill="var(--unreached)" /> <circle v-else :cx="node.x" :cy="node.y" r="3" fill="var(--unreached)" />
</g> </g>
</svg> </svg>
@ -95,4 +115,25 @@ function edgeEnd(edge: number[], side: 0 | 1) {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
} }
/* Nodes reached this round pop in: scale 0.6 to 1.0 plus fade, 250 ms
ease-out (spec 5.4). The global reduced-motion rule collapses this to a
discrete swap. */
.pop {
animation: pop 250ms ease-out backwards;
transform-box: fill-box;
transform-origin: center;
}
@keyframes pop {
from {
transform: scale(0.6);
opacity: 0.2;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style> </style>

View file

@ -0,0 +1,331 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { usePlayback } from '@/composables/usePlayback'
import { useSimStore } from '@/composables/useSimStore'
// The centered player pill (spec 5.4): replay, step back, play/pause,
// step forward, a native range scrubber (one tick per round), the round
// counter over the global max, and the speed pill. Space, ArrowLeft/Right
// and Home/End work globally except in text inputs; on the scrubber the
// browser handles them natively.
const store = useSimStore()
const playback = usePlayback()
const scrubber = ref<HTMLInputElement | null>(null)
const finalRound = computed(() => store.maxRounds())
const hasRounds = computed(() => finalRound.value > 0)
const atEnd = computed(() => store.state.round >= finalRound.value)
const progressPct = computed(() =>
hasRounds.value ? (store.state.round / finalRound.value) * 100 : 0,
)
function onScrubInput(event: Event) {
playback.seekTo(Number((event.target as HTMLInputElement).value))
}
function onGlobalKeydown(event: KeyboardEvent) {
const target = event.target instanceof HTMLElement ? event.target : null
// Text-like fields keep their keys; the scrubber handles arrows and
// Home/End natively; buttons keep Space for activation.
if (target?.closest('input:not([type=range]), textarea, select, [contenteditable="true"]')) return
const onScrubber = target === scrubber.value
switch (event.key) {
case ' ':
if (!onScrubber && target?.closest('button, a, summary, [role="dialog"]')) return
event.preventDefault()
playback.toggle()
break
case 'ArrowLeft':
if (onScrubber) return
event.preventDefault()
playback.stepBack()
break
case 'ArrowRight':
if (onScrubber) return
event.preventDefault()
playback.stepForward()
break
case 'Home':
if (onScrubber) return
event.preventDefault()
playback.seekTo(0)
playback.announceRound()
break
case 'End':
if (onScrubber) return
event.preventDefault()
playback.seekTo(finalRound.value)
playback.announceRound()
break
}
}
onMounted(() => document.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeydown))
</script>
<template>
<div class="playerwrap">
<div class="player">
<button
class="iconbtn"
type="button"
:disabled="!hasRounds"
aria-label="Replay from the start"
@click="playback.replay()"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4" /></svg>
</button>
<button
class="iconbtn step"
type="button"
:disabled="!hasRounds || store.state.round === 0"
aria-label="Back one round"
@click="playback.stepBack()"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M17 6l-7 6 7 6M7 6v12" /></svg>
</button>
<button
class="iconbtn play"
type="button"
:disabled="!hasRounds"
:aria-label="store.state.playing ? 'Pause' : atEnd ? 'Replay' : 'Play'"
@click="playback.toggle()"
>
<svg v-if="store.state.playing" class="ic filled" viewBox="0 0 24 24">
<path d="M7.5 5.5h3v13h-3zM13.5 5.5h3v13h-3z" />
</svg>
<svg v-else-if="atEnd" class="ic" viewBox="0 0 24 24">
<path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4" />
</svg>
<svg v-else class="ic filled" viewBox="0 0 24 24">
<path
d="M8.5 6.2v11.6c0 .8.9 1.3 1.6.9l9-5.8a1 1 0 0 0 0-1.8l-9-5.8a1.05 1.05 0 0 0-1.6.9z"
/>
</svg>
</button>
<button
class="iconbtn step"
type="button"
:disabled="!hasRounds || atEnd"
aria-label="Forward one round"
@click="playback.stepForward()"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M7 6l7 6-7 6M17 6v12" /></svg>
</button>
<div class="scrub">
<span
v-for="tick in Math.max(finalRound - 1, 0)"
:key="tick"
class="tick"
:style="{ left: `${(tick / finalRound) * 100}%` }"
aria-hidden="true"
/>
<input
ref="scrubber"
type="range"
min="0"
:max="finalRound"
step="1"
:value="store.state.round"
:disabled="!hasRounds"
:style="{ '--progress': `${progressPct}%` }"
aria-label="Round"
:aria-valuetext="`Round ${store.state.round} of ${finalRound}`"
@input="onScrubInput"
@change="playback.announceRound()"
/>
</div>
<span class="meta">Round {{ store.state.round }} of {{ finalRound }}</span>
<button
class="pill"
type="button"
:aria-label="`Playback speed ${store.state.speed}x, change`"
@click="playback.cycleSpeed()"
>
{{ store.state.speed }}&times;
</button>
</div>
</div>
</template>
<style scoped>
.playerwrap {
display: flex;
justify-content: center;
margin-top: 26px;
}
.player {
display: flex;
align-items: center;
gap: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: var(--shadow);
padding: 8px 18px 8px 10px;
width: min(680px, 100%);
}
.iconbtn {
width: 36px;
height: 36px;
border-radius: 999px;
border: none;
background: none;
color: var(--ink-3);
display: grid;
place-items: center;
cursor: pointer;
flex: none;
}
.iconbtn:hover:not(:disabled) {
background: var(--bg);
color: var(--ink);
}
.iconbtn:disabled {
opacity: 0.4;
cursor: default;
}
.iconbtn.play {
width: 42px;
height: 42px;
background: var(--ink);
color: var(--surface);
}
.iconbtn.play:hover:not(:disabled) {
background: var(--ink);
opacity: 0.9;
color: var(--surface);
}
svg.ic.filled {
fill: currentColor;
stroke: none;
}
.scrub {
flex: 1;
position: relative;
height: 28px;
margin: 0 10px;
display: flex;
align-items: center;
}
.tick {
position: absolute;
top: 12.5px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--surface);
border: 1px solid var(--ink-4);
transform: translateX(-2px);
pointer-events: none;
z-index: 1;
}
input[type='range'] {
appearance: none;
width: 100%;
height: 28px;
margin: 0;
background: transparent;
cursor: pointer;
}
input[type='range']:disabled {
cursor: default;
}
input[type='range']::-webkit-slider-runnable-track {
height: 3px;
border-radius: 3px;
background: linear-gradient(
to right,
var(--ink) var(--progress, 0%),
var(--border) var(--progress, 0%)
);
}
input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 17px;
height: 17px;
margin-top: -7px;
border-radius: 50%;
background: var(--surface);
border: 2px solid var(--ink);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25);
position: relative;
z-index: 2;
}
input[type='range']::-moz-range-track {
height: 3px;
border-radius: 3px;
background: var(--border);
}
input[type='range']::-moz-range-progress {
height: 3px;
border-radius: 3px;
background: var(--ink);
}
input[type='range']::-moz-range-thumb {
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--surface);
border: 2px solid var(--ink);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25);
}
.meta {
font-size: 13px;
font-weight: 550;
color: var(--ink-3);
white-space: nowrap;
}
.pill {
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
background: none;
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 11px;
margin-left: 10px;
cursor: pointer;
}
@media (max-width: 760px) {
.playerwrap {
position: fixed;
left: 12px;
right: 12px;
bottom: 12px;
z-index: 15;
margin: 0;
}
.player {
box-shadow: var(--shadow-lift);
}
.player .pill,
.player .iconbtn.step {
display: none;
}
}
</style>

View file

@ -2,7 +2,7 @@
import { computed } from 'vue' 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 { formatPct, roundPct } from '@/lib/format' import { roundPct } from '@/lib/format'
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 }>()
@ -11,10 +11,19 @@ const store = useSimStore()
const result = computed(() => store.state.resultsByPanelId[props.panel.id]) const result = computed(() => store.state.resultsByPanelId[props.panel.id])
const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents) const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents)
// The numbers follow the playhead: they count what the network picture
// 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
})
const reachedPctNow = computed(() => (reachedCount.value / numStudents.value) * 100)
const tone = computed(() => const tone = computed(() =>
result.value && roundPct(result.value.reachedPct) <= store.preset.toneThresholdPct roundPct(reachedPctNow.value) <= store.preset.toneThresholdPct ? 'good' : 'bad',
? 'good'
: 'bad',
) )
interface PanelChip { interface PanelChip {
@ -48,12 +57,9 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
</button> </button>
</div> </div>
<template v-if="result"> <template v-if="result">
<div class="pct" :class="tone"> <div class="pct" :class="tone">{{ roundPct(reachedPctNow) }}<small>%</small></div>
{{ roundPct(result.reachedPct) }}<small>%</small>
<span class="visually-hidden">{{ formatPct(result.reachedPct) }} reached</span>
</div>
<div class="meta"> <div class="meta">
<span>{{ result.numReached }} of {{ numStudents }} reached</span> <span>{{ reachedCount }} of {{ numStudents }} reached</span>
<span class="sep" aria-hidden="true"></span> <span class="sep" aria-hidden="true"></span>
<span>{{ result.numRounds }} rounds</span> <span>{{ result.numRounds }} rounds</span>
<span class="sep" aria-hidden="true"></span> <span class="sep" aria-hidden="true"></span>

View file

@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPlayback, REDUCED_MOTION_ROUND_MS, ROUND_MS } from '../usePlayback'
import { createSimStore } from '../useSimStore'
import { runScenario } from '@/lib/api'
import type { ScenarioRequest, ScenarioResponse } from '@/types/api'
vi.mock('@/lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/api')>()
return { ...actual, runScenario: vi.fn<typeof actual.runScenario>() }
})
const runScenarioMock = vi.mocked(runScenario)
// Every panel runs 5 rounds in this fake engine.
const FINAL_ROUND = 5
function fakeResponse(request: ScenarioRequest): ScenarioResponse {
const { numStudents } = request.config
return {
config: request.config,
result: {
strategy: request.strategy,
educated: [],
reachedAtRound: Array.from({ length: numStudents }, (_, node) =>
node < FINAL_ROUND ? node : -1,
),
numReached: FINAL_ROUND,
numRounds: FINAL_ROUND,
reachedPct: (FINAL_ROUND / numStudents) * 100,
},
edges: [[0, 1]],
}
}
let prefersReducedMotion = false
beforeEach(() => {
vi.useFakeTimers({
toFake: [
'setTimeout',
'clearTimeout',
'requestAnimationFrame',
'cancelAnimationFrame',
'performance',
],
})
runScenarioMock.mockReset()
runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
prefersReducedMotion = false
vi.stubGlobal('matchMedia', (query: string) => ({
matches: prefersReducedMotion && query.includes('prefers-reduced-motion'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}))
window.history.replaceState(null, '', '/')
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
async function readyStore() {
const store = createSimStore()
await store.initialize('')
return store
}
describe('usePlayback', () => {
it('autoplays once from round 0 and advances on the 700 ms cadence', async () => {
const store = await readyStore()
const playback = createPlayback(store)
playback.autoplayOnce()
expect(store.state.round).toBe(0)
expect(store.state.playing).toBe(true)
await vi.advanceTimersByTimeAsync(ROUND_MS + 20)
expect(store.state.round).toBe(1)
await vi.advanceTimersByTimeAsync(ROUND_MS)
expect(store.state.round).toBe(2)
})
it('stops on the final round, announces, and replays via play', async () => {
const store = await readyStore()
const playback = createPlayback(store)
playback.autoplayOnce()
await vi.advanceTimersByTimeAsync(ROUND_MS * (FINAL_ROUND + 3))
expect(store.state.round).toBe(FINAL_ROUND)
expect(store.state.playing).toBe(false) // no loop
expect(store.state.announcement).toContain('Final:')
playback.play() // at the end, play is the replay affordance
expect(store.state.round).toBe(0)
expect(store.state.playing).toBe(true)
})
it('autoplays only once', async () => {
const store = await readyStore()
const playback = createPlayback(store)
playback.autoplayOnce()
await vi.advanceTimersByTimeAsync(ROUND_MS * (FINAL_ROUND + 3))
playback.autoplayOnce()
expect(store.state.playing).toBe(false)
})
it('doubles the cadence at 2x speed', async () => {
const store = await readyStore()
const playback = createPlayback(store)
playback.cycleSpeed() // 1x -> 2x
expect(store.state.speed).toBe(2)
playback.play()
await vi.advanceTimersByTimeAsync(ROUND_MS / 2 + 20)
expect(store.state.round).toBe(1)
})
it('steps and seeks pause playback and clamp to the round range', async () => {
const store = await readyStore()
const playback = createPlayback(store)
playback.play()
playback.stepForward()
expect(store.state.playing).toBe(false)
expect(store.state.round).toBe(1)
playback.seekTo(99)
expect(store.state.round).toBe(FINAL_ROUND)
playback.stepForward()
expect(store.state.round).toBe(FINAL_ROUND)
playback.seekTo(-3)
expect(store.state.round).toBe(0)
playback.stepBack()
expect(store.state.round).toBe(0)
playback.announceRound()
expect(store.state.announcement).toBe(`Round 0 of ${FINAL_ROUND}`)
})
it('pausing announces the current round', async () => {
const store = await readyStore()
const playback = createPlayback(store)
playback.play()
await vi.advanceTimersByTimeAsync(ROUND_MS + 20)
playback.pause()
expect(store.state.announcement).toBe(`Paused at round 1 of ${FINAL_ROUND}`)
})
it('under reduced motion: no autoplay, page settles on the final round, slower cadence', async () => {
prefersReducedMotion = true
const store = await readyStore()
const playback = createPlayback(store)
playback.autoplayOnce()
expect(store.state.playing).toBe(false)
expect(store.state.round).toBe(FINAL_ROUND)
playback.replay()
expect(store.state.playing).toBe(true)
await vi.advanceTimersByTimeAsync(ROUND_MS + 20)
expect(store.state.round).toBe(0) // 700 ms is not enough at reduced motion
await vi.advanceTimersByTimeAsync(REDUCED_MOTION_ROUND_MS - ROUND_MS + 20)
expect(store.state.round).toBe(1)
})
})

View file

@ -0,0 +1,150 @@
import { useSimStore, type SimStore } from './useSimStore'
// Playback (spec 5.4): one global round drives every panel, the chart
// playhead, and the focus modal. Rounds advance on a rAF timer, 700 ms per
// round at 1x (1000 ms under reduced motion, which also disables the
// one-time autoplay). Playback never loops; at the final round the play
// button becomes a replay affordance.
export const ROUND_MS = 700
export const REDUCED_MOTION_ROUND_MS = 1000
const SPEED_CYCLE: Record<number, 0.5 | 1 | 2> = { 0.5: 1, 1: 2, 2: 0.5 }
export function createPlayback(store: SimStore = useSimStore()) {
const { state } = store
let frameId: number | null = null
let lastAdvanceAt = 0
let autoplayDone = false
function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function roundIntervalMs(): number {
return (prefersReducedMotion() ? REDUCED_MOTION_ROUND_MS : ROUND_MS) / state.speed
}
function stopFrameLoop() {
if (frameId !== null) cancelAnimationFrame(frameId)
frameId = null
}
function finishPlayback() {
state.playing = false
stopFrameLoop()
state.announcement = `Final: ${store.resultsSummary()}`
}
function onFrame(timestamp: number) {
if (!state.playing) {
frameId = null
return
}
if (timestamp - lastAdvanceAt >= roundIntervalMs()) {
lastAdvanceAt = timestamp
state.round += 1
if (state.round >= store.maxRounds()) {
state.round = Math.min(state.round, store.maxRounds())
finishPlayback()
return
}
}
frameId = requestAnimationFrame(onFrame)
}
function play() {
if (store.maxRounds() === 0 || state.playing) return
if (state.round >= store.maxRounds()) state.round = 0 // replay affordance
state.playing = true
lastAdvanceAt = performance.now()
frameId = requestAnimationFrame(onFrame)
}
function pause() {
if (!state.playing) return
state.playing = false
stopFrameLoop()
state.announcement = `Paused at round ${state.round} of ${store.maxRounds()}`
}
function toggle() {
if (state.playing) {
pause()
} else {
play()
}
}
// Stepping and seeking pause playback without a "paused" announcement;
// the seek announcement happens on scrub release (announceRound).
function quietPause() {
state.playing = false
stopFrameLoop()
}
function stepBack() {
quietPause()
state.round = Math.max(0, state.round - 1)
}
function stepForward() {
quietPause()
state.round = Math.min(store.maxRounds(), state.round + 1)
}
function seekTo(round: number) {
quietPause()
state.round = Math.min(store.maxRounds(), Math.max(0, Math.round(round)))
}
function announceRound() {
state.announcement = `Round ${state.round} of ${store.maxRounds()}`
}
function replay() {
quietPause()
state.round = 0
play()
}
function cycleSpeed() {
state.speed = SPEED_CYCLE[state.speed] ?? 1
}
// Once, after the first successful load (spec 5.1): render round 0, then
// play. Under reduced motion the page settles on the final round instead.
function autoplayOnce() {
if (autoplayDone) return
autoplayDone = true
if (store.maxRounds() === 0) return
if (prefersReducedMotion()) {
state.round = store.maxRounds()
return
}
state.round = 0
play()
}
return {
play,
pause,
toggle,
stepBack,
stepForward,
seekTo,
announceRound,
replay,
cycleSpeed,
autoplayOnce,
}
}
export type Playback = ReturnType<typeof createPlayback>
let activePlayback: Playback | null = null
export function usePlayback(): Playback {
activePlayback ??= createPlayback()
return activePlayback
}

View file

@ -1,5 +1,6 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import { ApiError, runScenario } from '@/lib/api' import { ApiError, runScenario } from '@/lib/api'
import { roundPct } from '@/lib/format'
import { graphKey } from '@/lib/graph' import { graphKey } from '@/lib/graph'
import { parseUrlState, serializeUrlState } from '@/lib/urlState' import { parseUrlState, serializeUrlState } from '@/lib/urlState'
import { deepfakeSchoolPreset } from '@/presets/deepfake-school' import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
@ -32,6 +33,7 @@ export interface SimState {
errorMessage: string | null // network/server failure, shown in the ErrorBanner errorMessage: string | null // network/server failure, shown in the ErrorBanner
validationError: string | null // 400 from the engine, shown inline under the controls validationError: string | null // 400 from the engine, shown inline under the controls
urlStateInvalid: boolean // the opened link held malformed state; preset shown instead urlStateInvalid: boolean // the opened link held malformed state; preset shown instead
announcement: string // text for the single polite live region (spec section 8)
} }
function createPanelId(): string { function createPanelId(): string {
@ -57,6 +59,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
errorMessage: null, errorMessage: null,
validationError: null, validationError: null,
urlStateInvalid: false, urlStateInvalid: false,
announcement: '',
}) })
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
@ -82,6 +85,19 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
}, 0) }, 0)
} }
// One spoken line per state of the world, e.g. "No program 83 percent,
// Random picks 58 percent". Used by the live region for run updates and
// by playback for the final announcement.
function resultsSummary(): string {
return state.panels
.map((panel) => {
const result = state.resultsByPanelId[panel.id]
return result ? `${panel.label} ${roundPct(result.reachedPct)} percent` : null
})
.filter(Boolean)
.join(', ')
}
function scheduleRun(panelId?: string) { function scheduleRun(panelId?: string) {
if (panelId === undefined) { if (panelId === undefined) {
pendingPanelIds = null pendingPanelIds = null
@ -144,6 +160,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
state.errorMessage = null state.errorMessage = null
state.validationError = null state.validationError = null
state.round = maxRounds() state.round = maxRounds()
state.announcement = `Updated: ${resultsSummary()}`
syncToUrl() syncToUrl()
} }
@ -274,6 +291,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
preset, preset,
effectiveConfig, effectiveConfig,
maxRounds, maxRounds,
resultsSummary,
initialize, initialize,
retry, retry,
setBaseField, setBaseField,