diff --git a/web/src/components/FocusModal.vue b/web/src/components/FocusModal.vue index 60a49e1..d94d604 100644 --- a/web/src/components/FocusModal.vue +++ b/web/src/components/FocusModal.vue @@ -5,7 +5,7 @@ import PlayerBar from './PlayerBar.vue' import { useSimStore } from '@/composables/useSimStore' import { accentForPanel } from '@/lib/accents' import { roundPct } from '@/lib/format' -import { reachedCountAtRound } from '@/lib/reach' +import { reachedCountDisplayed } from '@/lib/reach' // One panel rendered large (spec 5.8): same SVG, same global round, with // a PlayerBar docked at the modal footer (both bars share the store, so @@ -27,7 +27,9 @@ const numStudents = computed(() => panel.value ? store.effectiveConfig(panel.value).numStudents : 0, ) const reachedCount = computed(() => - result.value ? reachedCountAtRound(result.value.reachedAtRound, store.state.round) : 0, + result.value + ? reachedCountDisplayed(result.value.reachedAtRound, store.state.round, store.state.roundProgress) + : 0, ) const reachedPctNow = computed(() => (reachedCount.value / Math.max(numStudents.value, 1)) * 100) const tone = computed(() => diff --git a/web/src/components/MobileScoreStrip.vue b/web/src/components/MobileScoreStrip.vue index c893957..1f1c400 100644 --- a/web/src/components/MobileScoreStrip.vue +++ b/web/src/components/MobileScoreStrip.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { useSimStore } from '@/composables/useSimStore' import { roundPct } from '@/lib/format' -import { reachedCountAtRound } from '@/lib/reach' +import { reachedCountDisplayed } from '@/lib/reach' // Mobile only (spec section 9): a sticky strip of one pill per panel so // the comparison survives the single-column stack. The percentages follow @@ -22,7 +22,10 @@ const chips = computed(() => const result = store.state.resultsByPanelId[panel.id] if (!result) return { panelId: panel.id, label: panel.label, pctText: '…', tone: 'pending' } const numStudents = store.effectiveConfig(panel).numStudents - const pct = (reachedCountAtRound(result.reachedAtRound, store.state.round) / numStudents) * 100 + const pct = + (reachedCountDisplayed(result.reachedAtRound, store.state.round, store.state.roundProgress) / + numStudents) * + 100 return { panelId: panel.id, label: panel.label, diff --git a/web/src/components/NetworkView.vue b/web/src/components/NetworkView.vue index 92be418..a7d48c8 100644 --- a/web/src/components/NetworkView.vue +++ b/web/src/components/NetworkView.vue @@ -5,6 +5,7 @@ import { useNodeHover } from '@/composables/useNodeHover' import { ROUND_MS } from '@/composables/usePlayback' import { useSimStore } from '@/composables/useSimStore' import { graphKey } from '@/lib/graph' +import { APPEAR_WINDOW, appearanceJitter } from '@/lib/reach' import type { PanelSpec } from '@/presets/types' // One panel's network picture (spec section 6). Shape carries meaning, @@ -69,14 +70,15 @@ function onNodeClick(nodeIndex: number, event: MouseEvent) { } } -// The fade-in spans the whole round interval (scaled with playback speed) -// and each node starts at a small deterministic offset, so during playback -// something is always in motion; there is never a finished still frame -// between rounds. -const popDurationMs = computed(() => ROUND_MS / store.state.speed) +// Dots of the current round appear at deterministic random moments spread +// across the round interval (the same jitter drives the live counter in +// the panel stats), then fade in over the rest of the interval; playback +// never shows a finished still frame between rounds. +const roundIntervalMs = computed(() => ROUND_MS / store.state.speed) +const popDurationMs = computed(() => roundIntervalMs.value * (1 - APPEAR_WINDOW) + 120) function popDelay(nodeIndex: number): string { - return `${((nodeIndex * 37) % 150) / store.state.speed}ms` + return `${appearanceJitter(nodeIndex) * APPEAR_WINDOW * roundIntervalMs.value}ms` } diff --git a/web/src/components/ScenarioPanel.vue b/web/src/components/ScenarioPanel.vue index d1847f3..f762d7c 100644 --- a/web/src/components/ScenarioPanel.vue +++ b/web/src/components/ScenarioPanel.vue @@ -5,7 +5,7 @@ import PanelEditorPopover from './PanelEditorPopover.vue' import PanelMenu, { type PanelMenuItem } from './PanelMenu.vue' import { useSimStore } from '@/composables/useSimStore' import { roundPct } from '@/lib/format' -import { reachedCountAtRound } from '@/lib/reach' +import { reachedCountDisplayed } from '@/lib/reach' import { MAX_PANELS, type PanelSpec } from '@/presets/types' const props = defineProps<{ panel: PanelSpec; accent: string }>() @@ -61,11 +61,14 @@ function closeEditor() { 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. +// The numbers follow the playhead live: they count exactly the dots that +// have started appearing mid-transition, reaching the final outcome on +// the last round. const reachedCount = computed(() => { const panelResult = result.value - return panelResult ? reachedCountAtRound(panelResult.reachedAtRound, store.state.round) : 0 + return panelResult + ? reachedCountDisplayed(panelResult.reachedAtRound, store.state.round, store.state.roundProgress) + : 0 }) const reachedPctNow = computed(() => (reachedCount.value / numStudents.value) * 100) const tone = computed(() => diff --git a/web/src/composables/usePlayback.ts b/web/src/composables/usePlayback.ts index 1a3012a..cfe584b 100644 --- a/web/src/composables/usePlayback.ts +++ b/web/src/composables/usePlayback.ts @@ -32,6 +32,7 @@ export function createPlayback(store: SimStore = useSimStore()) { function finishPlayback() { state.playing = false + state.roundProgress = 1 stopFrameLoop() state.announcement = `Final: ${store.resultsSummary()}` } @@ -41,21 +42,32 @@ export function createPlayback(store: SimStore = useSimStore()) { frameId = null return } - if (timestamp - lastAdvanceAt >= roundIntervalMs()) { + const interval = roundIntervalMs() + const elapsed = timestamp - lastAdvanceAt + if (elapsed >= interval) { lastAdvanceAt = timestamp state.round += 1 + // The new round starts its transition: dots trickle in from here. + state.roundProgress = 0 if (state.round >= store.maxRounds()) { state.round = Math.min(state.round, store.maxRounds()) finishPlayback() return } + } else { + // Under reduced motion rounds swap discretely: always fully shown. + state.roundProgress = prefersReducedMotion() ? 1 : Math.min(elapsed / interval, 1) } frameId = requestAnimationFrame(onFrame) } function play() { if (store.maxRounds() === 0 || state.playing) return - if (state.round >= store.maxRounds()) state.round = 0 // replay affordance + if (state.round >= store.maxRounds()) { + // Replay affordance: restart with round 0's own transition. + state.round = 0 + state.roundProgress = 0 + } state.playing = true lastAdvanceAt = performance.now() frameId = requestAnimationFrame(onFrame) @@ -64,6 +76,7 @@ export function createPlayback(store: SimStore = useSimStore()) { function pause() { if (!state.playing) return state.playing = false + state.roundProgress = 1 // hold the full current round while paused stopFrameLoop() state.announcement = `Paused at round ${state.round} of ${store.maxRounds()}` } @@ -80,6 +93,7 @@ export function createPlayback(store: SimStore = useSimStore()) { // the seek announcement happens on scrub release (announceRound). function quietPause() { state.playing = false + state.roundProgress = 1 stopFrameLoop() } @@ -105,6 +119,7 @@ export function createPlayback(store: SimStore = useSimStore()) { function replay() { quietPause() state.round = 0 + state.roundProgress = 0 play() } @@ -123,6 +138,7 @@ export function createPlayback(store: SimStore = useSimStore()) { return } state.round = 0 + state.roundProgress = 0 play() } diff --git a/web/src/composables/useSimStore.ts b/web/src/composables/useSimStore.ts index 28360af..9bff27d 100644 --- a/web/src/composables/useSimStore.ts +++ b/web/src/composables/useSimStore.ts @@ -25,6 +25,9 @@ export interface SimState { resultsByPanelId: Record edgesByGraphHash: Record round: number + // How far the current round's transition has played, 0..1. Outside + // playback it is 1: the round is fully displayed. + roundProgress: number playing: boolean speed: PlaybackSpeed focusPanelId: string | null @@ -53,6 +56,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) { resultsByPanelId: {}, edgesByGraphHash: {}, round: 0, + roundProgress: 1, playing: false, speed: 1, focusPanelId: null, diff --git a/web/src/lib/__tests__/reach.spec.ts b/web/src/lib/__tests__/reach.spec.ts new file mode 100644 index 0000000..e7b3372 --- /dev/null +++ b/web/src/lib/__tests__/reach.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { reachedCountAtRound, reachedCountDisplayed } from '../reach' + +// Ten students: origin at round 0, then two per round for rounds 1-3, +// three never reached. +const reachedAtRound = [0, 1, 1, 2, 2, 3, 3, -1, -1, -1] + +describe('reachedCountAtRound', () => { + it('counts cumulatively and ignores never-reached nodes', () => { + expect(reachedCountAtRound(reachedAtRound, 0)).toBe(1) + expect(reachedCountAtRound(reachedAtRound, 2)).toBe(5) + expect(reachedCountAtRound(reachedAtRound, 99)).toBe(7) + }) +}) + +describe('reachedCountDisplayed', () => { + it('equals the per-round count when the transition has finished', () => { + for (let round = 0; round <= 3; round++) { + expect(reachedCountDisplayed(reachedAtRound, round, 1)).toBe( + reachedCountAtRound(reachedAtRound, round), + ) + } + }) + + it('grows monotonically through the transition, bounded by adjacent rounds', () => { + const round = 2 + const fullPreviousRound = reachedCountAtRound(reachedAtRound, round - 1) + const fullCurrentRound = reachedCountAtRound(reachedAtRound, round) + let previousCount = 0 + for (let step = 0; step <= 10; step++) { + const count = reachedCountDisplayed(reachedAtRound, round, step / 10) + expect(count).toBeGreaterThanOrEqual(fullPreviousRound) + expect(count).toBeLessThanOrEqual(fullCurrentRound) + expect(count).toBeGreaterThanOrEqual(previousCount) + previousCount = count + } + expect(previousCount).toBe(fullCurrentRound) + }) +}) diff --git a/web/src/lib/export.ts b/web/src/lib/export.ts new file mode 100644 index 0000000..06e7b1f --- /dev/null +++ b/web/src/lib/export.ts @@ -0,0 +1,225 @@ +import { NETWORK_VIEW_HEIGHT, NETWORK_VIEW_WIDTH } from '@/composables/useLayout' +import type { SimStore } from '@/composables/useSimStore' +import { accentForPanel } from '@/lib/accents' +import { roundPct } from '@/lib/format' +import { reachedCountDisplayed } from '@/lib/reach' + +// Export (spec 5.9). JSON and CSV come straight from store state; the PNG +// snapshot serializes each panel's live SVG (CSS variables resolved to the +// current theme's colors), draws them side by side with labels and +// percentages at the current round, and appends the legend and the +// disclaimer footer line, all at 2x resolution. + +function downloadBlob(blob: Blob, filename: string) { + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + link.click() + URL.revokeObjectURL(link.href) +} + +export function exportJson(store: SimStore) { + const payload = { + base: store.state.base, + panels: store.state.panels, + results: store.state.resultsByPanelId, + } + downloadBlob( + new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }), + 'spreadlab-export.json', + ) +} + +function csvCell(value: string): string { + return /[",\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value +} + +export function exportCsv(store: SimStore) { + const lines = ['panel,label,node,educated,reachedAtRound'] + store.state.panels.forEach((panel, panelIndex) => { + const result = store.state.resultsByPanelId[panel.id] + if (!result) return + const educatedNodes = new Set(result.educated) + result.reachedAtRound.forEach((reachedAt, node) => { + lines.push( + `${panelIndex},${csvCell(panel.label)},${node},${educatedNodes.has(node)},${reachedAt}`, + ) + }) + }) + downloadBlob(new Blob([`${lines.join('\n')}\n`], { type: 'text/csv' }), 'spreadlab-nodes.csv') +} + +// ---------- PNG snapshot ---------- + +const PNG_SCALE = 2 +const PANEL_WIDTH = NETWORK_VIEW_WIDTH * PNG_SCALE +const PANEL_HEIGHT = NETWORK_VIEW_HEIGHT * PNG_SCALE +const PAGE_PADDING = 48 +const PANEL_GAP = 32 +const HEADER_HEIGHT = 64 +const LEGEND_HEIGHT = 56 +const FOOTER_HEIGHT = 52 + +const TOKEN_NAMES = [ + '--bg', + '--surface', + '--border', + '--edge', + '--unreached', + '--spread', + '--edu', + '--ink', + '--ink-2', + '--ink-3', + '--ink-4', +] as const + +function resolveThemeTokens(): Record { + const rootStyle = getComputedStyle(document.documentElement) + const tokens: Record = {} + for (const name of TOKEN_NAMES) tokens[name] = rootStyle.getPropertyValue(name).trim() + return tokens +} + +function svgToImage(svgElement: SVGSVGElement, tokens: Record): Promise { + const clone = svgElement.cloneNode(true) as SVGSVGElement + clone.setAttribute('width', `${PANEL_WIDTH}`) + clone.setAttribute('height', `${PANEL_HEIGHT}`) + const markup = new XMLSerializer() + .serializeToString(clone) + .replace(/var\((--[a-z0-9-]+)\)/g, (_, tokenName: string) => tokens[tokenName] ?? '#000000') + const image = new Image() + return new Promise((resolve, reject) => { + image.onload = () => resolve(image) + image.onerror = reject + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}` + }) +} + +const FONT_STACK = "'Inter Variable', Inter, system-ui, sans-serif" + +export async function exportPng(store: SimStore): Promise { + const tokens = resolveThemeTokens() + const panelsWithSvg = store.state.panels.flatMap((panel, panelIndex) => { + const svgElement = document.querySelector(`#panel-${panel.id} svg.net`) + const result = store.state.resultsByPanelId[panel.id] + return svgElement && result ? [{ panel, panelIndex, svgElement, result }] : [] + }) + if (panelsWithSvg.length === 0) return + + const images = await Promise.all( + panelsWithSvg.map(({ svgElement }) => svgToImage(svgElement, tokens)), + ) + + const panelCount = panelsWithSvg.length + const width = 2 * PAGE_PADDING + panelCount * PANEL_WIDTH + (panelCount - 1) * PANEL_GAP + const height = + PAGE_PADDING + HEADER_HEIGHT + PANEL_HEIGHT + 20 + LEGEND_HEIGHT + FOOTER_HEIGHT + PAGE_PADDING / 2 + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) return + + ctx.fillStyle = tokens['--bg'] ?? '#ffffff' + ctx.fillRect(0, 0, width, height) + + panelsWithSvg.forEach(({ panel, panelIndex, result }, drawIndex) => { + const x = PAGE_PADDING + drawIndex * (PANEL_WIDTH + PANEL_GAP) + const numStudents = store.effectiveConfig(panel).numStudents + const pctNow = + (reachedCountDisplayed(result.reachedAtRound, store.state.round, store.state.roundProgress) / + numStudents) * + 100 + + // Header: accent swatch, label, percentage at the current round. + ctx.fillStyle = accentForPanel(panelIndex) + ctx.beginPath() + ctx.roundRect(x, PAGE_PADDING + 8, 18, 18, 5) + ctx.fill() + ctx.fillStyle = tokens['--ink-2'] ?? '#333' + ctx.font = `600 26px ${FONT_STACK}` + ctx.textBaseline = 'middle' + ctx.fillText(panel.label, x + 30, PAGE_PADDING + 18, PANEL_WIDTH - 140) + const pctLabel = `${roundPct(pctNow)}%` + ctx.fillStyle = + roundPct(pctNow) <= store.preset.toneThresholdPct + ? (tokens['--edu'] ?? '#14b8a6') + : (tokens['--spread'] ?? '#f43f5e') + ctx.font = `700 32px ${FONT_STACK}` + ctx.textAlign = 'right' + ctx.fillText(pctLabel, x + PANEL_WIDTH, PAGE_PADDING + 18) + ctx.textAlign = 'left' + + const image = images[drawIndex] + if (image) ctx.drawImage(image, x, PAGE_PADDING + HEADER_HEIGHT, PANEL_WIDTH, PANEL_HEIGHT) + }) + + // Legend row. + const legendY = PAGE_PADDING + HEADER_HEIGHT + PANEL_HEIGHT + 20 + LEGEND_HEIGHT / 2 + let legendX = PAGE_PADDING + ctx.font = `500 22px ${FONT_STACK}` + const legendEntries: { draw: (cx: number, cy: number) => void; text: string }[] = [ + { + text: 'Forwarded the fake', + draw: (cx, cy) => { + ctx.fillStyle = tokens['--spread'] ?? '#f43f5e' + ctx.beginPath() + ctx.arc(cx, cy, 9, 0, Math.PI * 2) + ctx.fill() + }, + }, + { + text: 'Educated to refuse', + draw: (cx, cy) => { + ctx.strokeStyle = tokens['--edu'] ?? '#14b8a6' + ctx.lineWidth = 4 + ctx.beginPath() + ctx.arc(cx, cy, 7, 0, Math.PI * 2) + ctx.stroke() + }, + }, + { + text: 'Not reached', + draw: (cx, cy) => { + ctx.fillStyle = tokens['--unreached'] ?? '#d8dfe9' + ctx.beginPath() + ctx.arc(cx, cy, 9, 0, Math.PI * 2) + ctx.fill() + }, + }, + { + text: 'Origin', + draw: (cx, cy) => { + ctx.strokeStyle = tokens['--spread'] ?? '#f43f5e' + ctx.lineWidth = 3.5 + ctx.beginPath() + ctx.arc(cx, cy, 8, 0, Math.PI * 2) + ctx.stroke() + }, + }, + ] + for (const entry of legendEntries) { + entry.draw(legendX + 9, legendY) + ctx.fillStyle = tokens['--ink-3'] ?? '#64748b' + ctx.fillText(entry.text, legendX + 28, legendY) + legendX += 28 + ctx.measureText(entry.text).width + 44 + } + + // Disclaimer footer line, always part of the picture (spec 5.12). + const footerY = legendY + LEGEND_HEIGHT / 2 + FOOTER_HEIGHT / 2 + ctx.fillStyle = tokens['--ink-4'] ?? '#94a3b8' + ctx.font = `italic 500 22px ${FONT_STACK}` + ctx.fillText( + 'Illustrative (not validated output) · an agent-based toy model, parameters chosen for clarity', + PAGE_PADDING, + footerY, + ) + ctx.textAlign = 'right' + ctx.font = `500 22px ${FONT_STACK}` + ctx.fillText('spreadlab', width - PAGE_PADDING, footerY) + ctx.textAlign = 'left' + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + if (blob) downloadBlob(blob, 'spreadlab.png') +} diff --git a/web/src/lib/reach.ts b/web/src/lib/reach.ts index 65f34a7..fadb9cb 100644 --- a/web/src/lib/reach.ts +++ b/web/src/lib/reach.ts @@ -1,5 +1,37 @@ +import { mulberry32 } from './mulberry32' + // 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 } + +// During playback, dots of the current round do not appear at once: each +// node gets a deterministic random moment inside the first APPEAR_WINDOW +// of the round interval (the rest of the interval is its fade). The same +// jitter drives the dot's animation-delay and the live counter, so the +// percentage ticks up exactly as dots start appearing. + +export const APPEAR_WINDOW = 0.7 + +export function appearanceJitter(nodeIndex: number): number { + return mulberry32(nodeIndex * 7919 + 17)() +} + +// Reach as displayed mid-transition: every earlier round fully, plus the +// current round's nodes whose appearance moment has passed. With +// roundProgress = 1 this equals reachedCountAtRound. +export function reachedCountDisplayed( + reachedAtRound: number[], + round: number, + roundProgress: number, +): number { + let displayed = 0 + reachedAtRound.forEach((reachedAt, nodeIndex) => { + if (reachedAt < 0 || reachedAt > round) return + if (reachedAt < round || roundProgress >= appearanceJitter(nodeIndex) * APPEAR_WINDOW) { + displayed += 1 + } + }) + return displayed +}