import type { CanvasRegion, VisualAction, VisualBeatGraph } from "./contracts"; export type CanvasObjectState = { objectId: string; kind: "particle_field" | "slinky" | "wave_graph" | "formula" | "numerical" | "note" | "recap" | "text" | "label" | "arrow" | "measurement"; region: CanvasRegion; text?: string; role?: "title" | "explanation" | "definition" | "caption" | "recap"; equation?: string; explanation?: string; tokens?: string[]; answer?: string; answerExplanation?: string; label?: string; dimmed?: boolean; highlighted?: boolean; appearedAtMs: number; }; export type ContinuousCanvasState = { objects: CanvasObjectState[]; activeBeatIndex: number; activeBeatProgress: number; }; function defaultTextRegion(action: Extract): CanvasRegion { if (action.role === "title") return "title"; if (action.role === "recap") return "centre"; return "right"; } export function deriveContinuousCanvasState(graph: VisualBeatGraph, timeMs: number): ContinuousCanvasState { const objects = new Map(); let activeBeatIndex = graph.beats.findIndex( (beat) => timeMs >= beat.estimatedStartMs && timeMs < beat.estimatedEndMs, ); if (activeBeatIndex < 0) activeBeatIndex = Math.max(0, graph.beats.length - 1); for (const beat of graph.beats) { if (beat.estimatedStartMs > timeMs) break; for (const action of beat.actions) { applyAction(objects, action, beat.estimatedStartMs); } } const active = graph.beats[activeBeatIndex]; const activeBeatProgress = active ? Math.max(0, Math.min(1, (timeMs - active.estimatedStartMs) / (active.estimatedEndMs - active.estimatedStartMs))) : 0; return { objects: [...objects.values()], activeBeatIndex, activeBeatProgress }; } function applyAction(objects: Map, action: VisualAction, appearedAtMs: number) { switch (action.type) { case "add_object": objects.set(action.objectId, { objectId: action.objectId, kind: action.objectType, region: action.region, appearedAtMs, }); return; case "remove_object": objects.delete(action.objectId); return; case "clear_region": for (const [id, object] of objects) { if (object.region === action.region) objects.delete(id); } return; case "move_object": { const object = objects.get(action.objectId); if (object) objects.set(action.objectId, { ...object, region: action.region }); return; } case "write_text": objects.set(action.objectId, { objectId: action.objectId, kind: "text", region: defaultTextRegion(action), text: action.text, role: action.role, appearedAtMs, }); return; case "write_equation": { const current = objects.get(action.objectId); objects.set(action.objectId, { objectId: action.objectId, kind: "formula", region: current?.region ?? "formula", equation: action.equation, explanation: action.explanation, tokens: action.tokens, appearedAtMs, }); return; } case "question": { const current = objects.get(action.objectId); objects.set(action.objectId, { objectId: action.objectId, kind: "numerical", region: current?.region ?? "centre", text: action.prompt, appearedAtMs, }); return; } case "answer_reveal": { const current = objects.get(action.objectId); if (current) { objects.set(action.objectId, { ...current, answer: action.answer, answerExplanation: action.explanation, }); } return; } case "reveal_label": objects.set(action.objectId, { objectId: action.objectId, kind: "label", region: "centre", text: action.text, label: action.targetId, appearedAtMs, }); return; case "draw_arrow": objects.set(action.objectId, { objectId: action.objectId, kind: "arrow", region: "centre", label: action.label, appearedAtMs, }); return; case "show_measurement": objects.set(action.objectId, { objectId: action.objectId, kind: "measurement", region: "centre", label: action.label, appearedAtMs, }); return; case "highlight": { const object = objects.get(action.targetId); if (object) objects.set(action.targetId, { ...object, highlighted: true, dimmed: false }); return; } case "dim": { const object = objects.get(action.targetId); if (object) objects.set(action.targetId, { ...object, dimmed: true }); return; } case "focus": { const object = objects.get(action.targetId); if (object) objects.set(action.targetId, { ...object, dimmed: false }); return; } default: return; } }