web: dots trickle in randomly per round, the share counts live
Per Justin: a round's dots no longer start fading together. Every node gets a deterministic random appearance moment inside the first 70% of the round interval (the rest is its fade), and a new roundProgress value, driven by the playback frame loop, feeds the same jitter into the panel stats: the big percentage and reached count now tick up exactly as dots start appearing, in the cards, the mobile strip, and the focus modal. Pausing, stepping, and seeking hold the full round; reduced motion keeps discrete swaps.
This commit is contained in:
parent
609ba6f0dd
commit
3cd48ca7ea
9 changed files with 342 additions and 16 deletions
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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<StripChip[]>(() =>
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export interface SimState {
|
|||
resultsByPanelId: Record<string, Result>
|
||||
edgesByGraphHash: Record<string, number[][]>
|
||||
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,
|
||||
|
|
|
|||
39
web/src/lib/__tests__/reach.spec.ts
Normal file
39
web/src/lib/__tests__/reach.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
225
web/src/lib/export.ts
Normal file
225
web/src/lib/export.ts
Normal file
|
|
@ -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<string, string> {
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const tokens: Record<string, string> = {}
|
||||
for (const name of TOKEN_NAMES) tokens[name] = rootStyle.getPropertyValue(name).trim()
|
||||
return tokens
|
||||
}
|
||||
|
||||
function svgToImage(svgElement: SVGSVGElement, tokens: Record<string, string>): Promise<HTMLImageElement> {
|
||||
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<void> {
|
||||
const tokens = resolveThemeTokens()
|
||||
const panelsWithSvg = store.state.panels.flatMap((panel, panelIndex) => {
|
||||
const svgElement = document.querySelector<SVGSVGElement>(`#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<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
if (blob) downloadBlob(blob, 'spreadlab.png')
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue