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:
parent
71baf10e73
commit
0e3a14a0b3
7 changed files with 736 additions and 13 deletions
|
|
@ -6,16 +6,20 @@ import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
|
|||
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 ResultsTable from '@/components/ResultsTable.vue'
|
||||
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
|
||||
import { usePlayback } from '@/composables/usePlayback'
|
||||
import { useSimStore } from '@/composables/useSimStore'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const store = useSimStore()
|
||||
const playback = usePlayback()
|
||||
useTheme() // resolve and apply the theme before first paint of the app
|
||||
|
||||
onMounted(() => {
|
||||
void store.initialize()
|
||||
onMounted(async () => {
|
||||
await store.initialize()
|
||||
playback.autoplayOnce()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -27,6 +31,7 @@ onMounted(() => {
|
|||
<ErrorBanner />
|
||||
<PanelGrid />
|
||||
<LegendRow />
|
||||
<PlayerBar />
|
||||
<ResultsTable
|
||||
:panels="store.state.panels"
|
||||
:results-by-panel-id="store.state.resultsByPanelId"
|
||||
|
|
@ -34,6 +39,7 @@ onMounted(() => {
|
|||
/>
|
||||
</main>
|
||||
<FooterDisclaimer />
|
||||
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface RenderedNode {
|
|||
x: number
|
||||
y: number
|
||||
reached: boolean
|
||||
justReached: boolean // reached exactly this round: animates in (spec 5.4)
|
||||
educated: boolean
|
||||
isOrigin: boolean
|
||||
}
|
||||
|
|
@ -33,10 +34,12 @@ const nodes = computed<RenderedNode[]>(() => {
|
|||
const educatedNodes = new Set(panelResult.educated)
|
||||
return layout.value.map((point, nodeIndex) => {
|
||||
const reachedAtRound = panelResult.reachedAtRound[nodeIndex] ?? -1
|
||||
const reached = reachedAtRound >= 0 && reachedAtRound <= store.state.round
|
||||
return {
|
||||
x: point.x,
|
||||
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),
|
||||
isOrigin: nodeIndex === effectiveConfig.value.origin,
|
||||
}
|
||||
|
|
@ -46,6 +49,13 @@ const nodes = computed<RenderedNode[]>(() => {
|
|||
function edgeEnd(edge: number[], side: 0 | 1) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -75,6 +85,8 @@ function edgeEnd(edge: number[], side: 0 | 1) {
|
|||
</template>
|
||||
<circle
|
||||
v-else-if="node.educated"
|
||||
:class="{ pop: node.justReached }"
|
||||
:style="node.justReached ? { animationDelay: popDelay(nodeIndex) } : undefined"
|
||||
:cx="node.x"
|
||||
:cy="node.y"
|
||||
r="3.8"
|
||||
|
|
@ -82,7 +94,15 @@ function edgeEnd(edge: number[], side: 0 | 1) {
|
|||
stroke="var(--edu)"
|
||||
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)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
@ -95,4 +115,25 @@ function edgeEnd(edge: number[], side: 0 | 1) {
|
|||
display: block;
|
||||
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>
|
||||
|
|
|
|||
331
web/src/components/PlayerBar.vue
Normal file
331
web/src/components/PlayerBar.vue
Normal 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 }}×
|
||||
</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>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import { computed } from 'vue'
|
||||
import NetworkView from './NetworkView.vue'
|
||||
import { useSimStore } from '@/composables/useSimStore'
|
||||
import { formatPct, roundPct } from '@/lib/format'
|
||||
import { roundPct } from '@/lib/format'
|
||||
import type { PanelSpec } from '@/presets/types'
|
||||
|
||||
const props = defineProps<{ panel: PanelSpec; accent: string }>()
|
||||
|
|
@ -11,10 +11,19 @@ const store = useSimStore()
|
|||
|
||||
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
|
||||
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(() =>
|
||||
result.value && roundPct(result.value.reachedPct) <= store.preset.toneThresholdPct
|
||||
? 'good'
|
||||
: 'bad',
|
||||
roundPct(reachedPctNow.value) <= store.preset.toneThresholdPct ? 'good' : 'bad',
|
||||
)
|
||||
|
||||
interface PanelChip {
|
||||
|
|
@ -48,12 +57,9 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
|||
</button>
|
||||
</div>
|
||||
<template v-if="result">
|
||||
<div class="pct" :class="tone">
|
||||
{{ roundPct(result.reachedPct) }}<small>%</small>
|
||||
<span class="visually-hidden">{{ formatPct(result.reachedPct) }} reached</span>
|
||||
</div>
|
||||
<div class="pct" :class="tone">{{ roundPct(reachedPctNow) }}<small>%</small></div>
|
||||
<div class="meta">
|
||||
<span>{{ result.numReached }} of {{ numStudents }} reached</span>
|
||||
<span>{{ reachedCount }} of {{ numStudents }} reached</span>
|
||||
<span class="sep" aria-hidden="true">•</span>
|
||||
<span>{{ result.numRounds }} rounds</span>
|
||||
<span class="sep" aria-hidden="true">•</span>
|
||||
|
|
|
|||
171
web/src/composables/__tests__/usePlayback.spec.ts
Normal file
171
web/src/composables/__tests__/usePlayback.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
150
web/src/composables/usePlayback.ts
Normal file
150
web/src/composables/usePlayback.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { reactive } from 'vue'
|
||||
import { ApiError, runScenario } from '@/lib/api'
|
||||
import { roundPct } from '@/lib/format'
|
||||
import { graphKey } from '@/lib/graph'
|
||||
import { parseUrlState, serializeUrlState } from '@/lib/urlState'
|
||||
import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
|
||||
|
|
@ -32,6 +33,7 @@ export interface SimState {
|
|||
errorMessage: string | null // network/server failure, shown in the ErrorBanner
|
||||
validationError: string | null // 400 from the engine, shown inline under the controls
|
||||
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 {
|
||||
|
|
@ -57,6 +59,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
|||
errorMessage: null,
|
||||
validationError: null,
|
||||
urlStateInvalid: false,
|
||||
announcement: '',
|
||||
})
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
|
@ -82,6 +85,19 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
|||
}, 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) {
|
||||
if (panelId === undefined) {
|
||||
pendingPanelIds = null
|
||||
|
|
@ -144,6 +160,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
|||
state.errorMessage = null
|
||||
state.validationError = null
|
||||
state.round = maxRounds()
|
||||
state.announcement = `Updated: ${resultsSummary()}`
|
||||
syncToUrl()
|
||||
}
|
||||
|
||||
|
|
@ -274,6 +291,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
|||
preset,
|
||||
effectiveConfig,
|
||||
maxRounds,
|
||||
resultsSummary,
|
||||
initialize,
|
||||
retry,
|
||||
setBaseField,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue