/* ============================================================ Tutori Whiteboard Engine Hand-drawn canvas renderer + audio-synced lesson playback. Logical coordinate space: 100 (x) by 75 (y), y grows down. ============================================================ */ (() => { "use strict"; const LOGICAL_W = 100, LOGICAL_H = 75; const PALETTE = { ink: "#27272a", blue: "#2563eb", red: "#dc2626", green: "#15803d", orange: "#ea580c", purple: "#7c3aed", gray: "#94a3b8", yellow: "#facc15", }; const HILITE = "rgba(250, 204, 21, 0.38)"; // ---------- deterministic per-op randomness ---------- function mulberry32(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ---------- state ---------- const S = { mounted: false, wrap: null, agentCv: null, liveCv: null, userCv: null, actx: null, bakedOps: [], // committed agent ops [{op, seed}] userStrokes: [], // [{color, size, eraser, pts:[[x,y]..]}] queue: [], playing: false, curTurn: null, enqueued: 0, curAudio: null, raf: 0, tool: { mode: "pen", color: "#2563eb", size: 0.45 }, scale: 1, offX: 0, offY: 0, opSeedCounter: 1, voiceOn: true, keepBoardUntil: 0, scrollTid: 0, }; function px([x, y]) { return [S.offX + x * S.scale, S.offY + y * S.scale]; } function lw(units) { return Math.max(1.4, units * S.scale); } // ---------- hand-drawn primitives ---------- // Build a jittered polyline between two points. function jitterSeg(rnd, x1, y1, x2, y2, wob) { const [ax, ay] = px([x1, y1]), [bx, by] = px([x2, y2]); const len = Math.hypot(bx - ax, by - ay); const n = Math.max(3, Math.round(len / 26)); const pts = []; for (let i = 0; i <= n; i++) { const t = i / n; const j = (i === 0 || i === n) ? 0.35 : 1; pts.push([ ax + (bx - ax) * t + (rnd() - 0.5) * wob * j, ay + (by - ay) * t + (rnd() - 0.5) * wob * j, ]); } return pts; } function strokePts(ctx, pts, frac, color, width) { if (pts.length < 2 || frac <= 0) return; const upto = Math.max(2, Math.ceil(pts.length * Math.min(1, frac))); ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < upto - 1; i++) { const mx = (pts[i][0] + pts[i + 1][0]) / 2, my = (pts[i][1] + pts[i + 1][1]) / 2; ctx.quadraticCurveTo(pts[i][0], pts[i][1], mx, my); } const last = pts[upto - 1]; ctx.lineTo(last[0], last[1]); ctx.stroke(); } function drawLine(ctx, rnd, a, b, color, frac, width, dash) { if (dash) ctx.setLineDash([7, 7]); // two passes for the sketchy double-stroke look const wob = Math.min(3.2, S.scale * 0.55); strokePts(ctx, jitterSeg(rnd, a[0], a[1], b[0], b[1], wob), frac, color, width); strokePts(ctx, jitterSeg(rnd, a[0], a[1], b[0], b[1], wob), frac, color, width * 0.55); ctx.setLineDash([]); } function drawArrowHead(ctx, a, b, color, width) { const [ax, ay] = px(a), [bx, by] = px(b); const ang = Math.atan2(by - ay, bx - ax); const L = Math.max(9, S.scale * 2.6); ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = "round"; ctx.moveTo(bx, by); ctx.lineTo(bx - L * Math.cos(ang - 0.45), by - L * Math.sin(ang - 0.45)); ctx.moveTo(bx, by); ctx.lineTo(bx - L * Math.cos(ang + 0.45), by - L * Math.sin(ang + 0.45)); ctx.stroke(); } function ellipsePts(rnd, cx, cy, rx, ry) { const [pcx, pcy] = px([cx, cy]); const prx = rx * S.scale, pry = ry * S.scale; const n = 34, start = rnd() * Math.PI * 2, pts = []; const wob = Math.min(2.6, S.scale * 0.45); for (let i = 0; i <= n; i++) { const t = start + (i / n) * Math.PI * 2.08; // slight overlap = sketchy close pts.push([ pcx + Math.cos(t) * prx + (rnd() - 0.5) * wob, pcy + Math.sin(t) * pry + (rnd() - 0.5) * wob, ]); } return pts; } function fontPx(size) { const base = { s: 3.0, m: 4.0, l: 5.1, xl: 6.2 }[size || "m"] || 4.0; return Math.max(11.5, base * S.scale); } // Split a word into normal/superscript runs: "3^2," -> 3, ²(sup), "," function parseSupers(word) { const segs = []; let i = 0, cur = ""; while (i < word.length) { if (word[i] === "^") { if (cur) { segs.push({ t: cur, sup: false }); cur = ""; } i++; let sup = ""; while (i < word.length && /[A-Za-z0-9+\-]/.test(word[i]) && sup.length < 4) { sup += word[i]; i++; } if (sup) segs.push({ t: sup, sup: true }); } else { cur += word[i]; i++; } } if (cur) segs.push({ t: cur, sup: false }); return segs; } function drawText(ctx, rnd, text, at, size, color, frac, align, fitW, fitH) { const fullStr = String(text); const visibleLen = fullStr.replace(/\^/g, "").length; let budget = Math.ceil(visibleLen * Math.min(1, frac)); if (budget <= 0) return; const [x, y] = px(at); let basePx = fontPx(size); if (fitW) { // shrink to stay inside the shape that owns this label ctx.font = `600 ${basePx}px Caveat, "Comic Sans MS", cursive`; const w1 = ctx.measureText(fullStr.replace(/\^/g, "")).width; const fitPx = fitW * S.scale; if (w1 > fitPx) basePx = Math.max(8.5, basePx * fitPx / w1); } if (fitH) { // and vertically (squat shapes) basePx = Math.max(8.5, Math.min(basePx, fitH * S.scale)); } const supPx = basePx * 0.62; ctx.save(); ctx.translate(x, y); ctx.rotate((rnd() - 0.5) * 0.035); ctx.fillStyle = color; ctx.textAlign = "left"; ctx.textBaseline = "middle"; const fontFor = (sup) => `600 ${sup ? supPx : basePx}px Caveat, "Comic Sans MS", cursive`; const words = fullStr.split(" ").map(w => { const segs = parseSupers(w); let wpx = 0; for (const s of segs) { ctx.font = fontFor(s.sup); s.w = ctx.measureText(s.t).width; wpx += s.w; } return { segs, wpx }; }); ctx.font = fontFor(false); const spaceW = ctx.measureText(" ").width; const maxW = (fitW || (size === "xl" ? 88 : 52)) * S.scale; // wrap into lines const lines = []; let line = [], lw = 0; for (const w of words) { if (lw > 0 && lw + spaceW + w.wpx > maxW) { lines.push({ line, lw }); line = []; lw = 0; } if (lw > 0) lw += spaceW; line.push(w); lw += w.wpx; } if (line.length) lines.push({ line, lw }); // draw, spending the reveal budget char by char let ly = 0; for (const L of lines) { let lx = (align === "center") ? -L.lw / 2 : 0; for (let wi = 0; wi < L.line.length; wi++) { if (budget <= 0) break; if (wi > 0) { lx += spaceW; budget--; } for (const s of L.line[wi].segs) { if (budget <= 0) break; const shown = s.t.slice(0, budget); ctx.font = fontFor(s.sup); ctx.fillText(shown, lx, ly + (s.sup ? -basePx * 0.42 : 0)); lx += ctx.measureText(shown).width; budget -= shown.length; } } if (budget <= 0) break; ly += basePx * 1.06; } ctx.restore(); } function catmullRom(ptsPx) { if (ptsPx.length < 3) return ptsPx; const out = []; for (let i = 0; i < ptsPx.length - 1; i++) { const p0 = ptsPx[Math.max(0, i - 1)], p1 = ptsPx[i], p2 = ptsPx[i + 1], p3 = ptsPx[Math.min(ptsPx.length - 1, i + 2)]; for (let t = 0; t < 1; t += 0.12) { const t2 = t * t, t3 = t2 * t; out.push([ 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3), ]); } } out.push(ptsPx[ptsPx.length - 1]); return out; } // ---------- op rendering (frac = 0..1 animation progress) ---------- function color(c) { return PALETTE[c] || c || PALETTE.ink; } function renderOp(ctx, item, frac) { const o = item.op, rnd = mulberry32(item.seed); const W = lw(0.55); try { switch (o.op) { case "title": { drawText(ctx, rnd, o.text, [50, 7], "xl", color(o.color || "ink"), frac, "center", 86, 7.2); if (frac > 0.75) { const tw = Math.min(70, String(o.text).length * 1.9); drawLine(ctx, rnd, [50 - tw / 2, 12], [50 + tw / 2, 12], color(o.color || "ink"), (frac - 0.75) / 0.25, lw(0.5)); } break; } case "text": drawText(ctx, rnd, o.text, o.at || [10, 20], o.size || "m", color(o.color), frac, o.align, o.fit_w); break; case "note": drawText(ctx, rnd, o.text, o.at || [10, 20], "s", color(o.color || "gray"), frac, o.align); break; case "line": drawLine(ctx, rnd, o.from, o.to, color(o.color), frac, W, o.dash); break; case "arrow": { const lineFrac = Math.min(1, frac / 0.8); drawLine(ctx, rnd, o.from, o.to, color(o.color), lineFrac, W); if (frac > 0.8) drawArrowHead(ctx, o.from, o.to, color(o.color), W); if (o.label && frac > 0.55) { // server may relocate the label to dodge collisions (label_at) const mid = o.label_at || [(o.from[0] + o.to[0]) / 2, (o.from[1] + o.to[1]) / 2 - 3.4]; drawText(ctx, rnd, o.label, mid, "s", color(o.color), (frac - 0.55) / 0.45, "center"); } break; } case "callout": { const c = o.around || o.at || [50, 38]; const r = o.r || 3; const dest = o.label_at || o.to || [c[0] + 12, c[1] - 8]; const dx = dest[0] - c[0], dy = dest[1] - c[1]; const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; const circleFrac = Math.min(1, frac / 0.45); strokePts(ctx, ellipsePts(rnd, c[0], c[1], r, r * 0.82), circleFrac, color(o.color || "orange"), W); if (frac > 0.25) { const lineFrac = Math.min(1, (frac - 0.25) / 0.45); const a = [c[0] + ux * (r + 0.5), c[1] + uy * (r + 0.5)]; const b = [dest[0] - ux * 4.0, dest[1] - uy * 4.0]; drawLine(ctx, rnd, a, b, color(o.color || "orange"), lineFrac, W * 0.85); } if (o.label && frac > 0.55) { drawText(ctx, rnd, o.label, dest, "s", color(o.color || "orange"), (frac - 0.55) / 0.45, "center", o.fit_w || 24); } break; } case "box": { const [x, y] = o.at, w = o.w || 22, h = o.h || 10; const segs = [ [[x, y], [x + w, y]], [[x + w, y], [x + w, y + h]], [[x + w, y + h], [x, y + h]], [[x, y + h], [x, y]], ]; const sf = Math.min(1, frac / 0.7) * 4; segs.forEach((s, i) => { const f = Math.max(0, Math.min(1, sf - i)); if (f > 0) drawLine(ctx, rnd, s[0], s[1], color(o.color), f, W); }); if (o.label && frac > 0.6) { drawText(ctx, rnd, o.label, [x + w / 2, y + h / 2], o.size || "m", color(o.color), (frac - 0.6) / 0.4, "center", w * 0.92, h * 0.72); } break; } case "ellipse": { const [x, y] = o.at, w = o.w || 22, h = o.h || 12; const pts = ellipsePts(rnd, x + w / 2, y + h / 2, w / 2, h / 2); strokePts(ctx, pts, Math.min(1, frac / 0.7), color(o.color), W); if (o.label && frac > 0.6) { drawText(ctx, rnd, o.label, [x + w / 2, y + h / 2], o.size || "m", color(o.color), (frac - 0.6) / 0.4, "center", w * 0.78, h * 0.62); } break; } case "curve": { const ptsPx = (o.points || []).map(px); if (ptsPx.length >= 2) { const smooth = catmullRom(ptsPx).map(p => [p[0] + (rnd() - 0.5) * 1.2, p[1] + (rnd() - 0.5) * 1.2]); strokePts(ctx, smooth, frac, color(o.color), W); } break; } case "polygon": { const pts = o.points || []; if (pts.length >= 3) { const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length; const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length; const segs = pts.map((p, i) => [p, pts[(i + 1) % pts.length]]); const drawFrac = Math.min(1, frac / 0.7) * segs.length; segs.forEach((s, i) => { const f = Math.max(0, Math.min(1, drawFrac - i)); if (f > 0) drawLine(ctx, rnd, s[0], s[1], color(o.color), f, W); }); // side labels: auto-placed outside each edge's midpoint if (Array.isArray(o.side_labels) && frac > 0.45) { o.side_labels.slice(0, pts.length).forEach((lb, i) => { if (!lb) return; const a = pts[i], b = pts[(i + 1) % pts.length]; const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2; let dx = mx - cx, dy = my - cy; const len = Math.hypot(dx, dy) || 1; drawText(ctx, rnd, lb, [mx + (dx / len) * 5, my + (dy / len) * 5], "s", color(o.color), (frac - 0.45) / 0.55, "center"); }); } if (o.label && frac > 0.65) { // fit the label to the polygon's interior: width of the // horizontal chord through the centroid const yc = cy + 0.07; // dodge exact-vertex degeneracy const xs = []; segs.forEach(([a, b]) => { if ((a[1] - yc) * (b[1] - yc) < 0) { xs.push(a[0] + (yc - a[1]) * (b[0] - a[0]) / (b[1] - a[1])); } }); let lx = cx, chord = 0; if (xs.length >= 2) { const lo = Math.min(...xs), hi = Math.max(...xs); chord = hi - lo; lx = (lo + hi) / 2; } else { const pxs = pts.map(p => p[0]); chord = (Math.max(...pxs) - Math.min(...pxs)) * 0.55; } drawText(ctx, rnd, o.label, [lx, cy], o.size || "m", color(o.color), (frac - 0.65) / 0.35, "center", chord * 0.82); } } break; } case "notes": { const [nx, ny] = o.at || [66, 20]; const compact = !!o.compact; // server shrinks crowded blocks const lines = []; if (o.title) lines.push({ t: String(o.title), size: compact ? "s" : "m" }); (o.lines || []).slice(0, 7).forEach(t => lines.push({ t: String(t), size: "s" })); const total = lines.reduce((s, l) => s + l.t.length, 0) || 1; let used = 0, yy = ny; for (let li = 0; li < lines.length; li++) { const ln = lines[li]; const f = Math.max(0, Math.min(1, (frac * total - used) / ln.t.length)); if (f > 0) { drawText(ctx, rnd, ln.t, [nx + (li > 0 ? (compact ? 1.5 : 2) : 0), yy], ln.size, color(o.color), f, "left"); } used += ln.t.length; const step = li === 0 && o.title ? (compact ? 4.8 : 6.2) : (compact ? 4.0 : 5.2); yy += step; } break; } case "axes": { const [x, y] = o.at || [16, 22], w = o.w || 46, h = o.h || 32; const f1 = Math.min(1, frac / 0.45), f2 = Math.max(0, Math.min(1, (frac - 0.45) / 0.45)); drawLine(ctx, rnd, [x, y], [x, y + h], color(o.color || "ink"), f1, W); if (f1 >= 1) drawArrowHead(ctx, [x, y + h], [x, y], color(o.color || "ink"), W); if (f2 > 0) { drawLine(ctx, rnd, [x, y + h], [x + w, y + h], color(o.color || "ink"), f2, W); if (f2 >= 1) drawArrowHead(ctx, [x, y + h], [x + w, y + h], color(o.color || "ink"), W); } if (frac > 0.9) { if (o.xlabel) drawText(ctx, rnd, o.xlabel, [x + w + 2, y + h], "s", "gray", 1, "left"); if (o.ylabel) drawText(ctx, rnd, o.ylabel, [x, y - 3], "s", "gray", 1, "center"); } break; } case "underline": case "highlight": { let [x, y] = o.at, w = o.w || 20; // the engine sizes the swipe from an ESTIMATE of the text width — // measure the actual drawn text and hug it instead const t = findTextSpan(ctx, x, y, w); if (t) { x = t.x - 0.6; w = t.w + 1.2; } const isH = o.op === "highlight"; const pts = jitterSeg(rnd, x, y, x + w, y, 2); const ctx2 = ctx; ctx2.save(); ctx2.globalAlpha = isH ? 0.45 : 1; strokePts(ctx2, pts, frac, isH ? HILITE.replace(/[\d.]+\)$/, "1)") : color(o.color || "orange"), isH ? lw(3.2) : lw(0.8)); ctx2.restore(); break; } case "dot": { const [x, y] = px(o.at); const r = Math.max(3, S.scale * 0.9) * Math.min(1, frac * 2); ctx.beginPath(); ctx.fillStyle = color(o.color); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); if (o.label && frac > 0.5) { const lp2 = o.label_at || [o.at[0] + 2.5, o.at[1] - 2.5]; drawText(ctx, rnd, o.label, lp2, "s", color(o.color), (frac - 0.5) / 0.5, o.label_at ? "center" : "left"); } break; } default: break; // unknown op: skip quietly } } catch (e) { /* never let one bad op kill the lesson */ } } // rough time-cost of an op, for scheduling within a step function opCost(o) { const d = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]); switch (o.op) { case "title": return 22 + String(o.text || "").length * 1.6; case "text": case "note": return 10 + String(o.text || "").length * 1.4; case "line": return 8 + d(o.from || [0, 0], o.to || [0, 0]); case "arrow": return 14 + d(o.from || [0, 0], o.to || [0, 0]) + String(o.label || "").length; case "callout": return 18 + d(o.around || [0, 0], o.to || o.label_at || [0, 0]) + String(o.label || "").length * 1.2; case "box": return 16 + 2 * ((o.w || 22) + (o.h || 10)) + String(o.label || "").length; case "ellipse": return 16 + 3.2 * ((o.w || 22) + (o.h || 12)) / 2 + String(o.label || "").length; case "curve": { let s = 10; const p = o.points || []; for (let i = 1; i < p.length; i++) s += d(p[i - 1], p[i]); return s; } case "polygon": { let s = 14 + String(o.label || "").length + (o.side_labels || []).join("").length; const p = o.points || []; for (let i = 0; i < p.length; i++) s += d(p[i], p[(i + 1) % p.length]); return s; } case "notes": { let s = 10 + String(o.title || "").length; (o.lines || []).forEach(t => { s += String(t).length; }); return s * 1.3; } case "axes": return 30 + (o.w || 46) + (o.h || 32); case "underline": case "highlight": return 8 + (o.w || 20); case "dot": return 8; case "clear": return 6; default: return 8; } } // find the baked text op a highlight/underline belongs to, with its // true rendered span in board units function findTextSpan(ctx, hx, hy, hw) { let best = null; for (const it of S.bakedOps) { const cand = (it && typeof it.op === "object") ? it.op : it; const t = (cand && cand.op === "text") ? cand : null; if (!t || !t.at || !t.text) continue; const dy = Math.abs(t.at[1] - hy); if (dy > 3.6) continue; ctx.save(); const bpx = fontPx(t.size || "m"); ctx.font = `600 ${bpx}px Caveat, "Comic Sans MS", cursive`; const spaceW = ctx.measureText(" ").width; let wpx = 0; const wordsArr = String(t.text).split(" "); wordsArr.forEach((word, wi) => { for (const seg of parseSupers(word)) { ctx.font = `600 ${(seg.sup ? 0.62 : 1) * bpx}px Caveat, "Comic Sans MS", cursive`; wpx += ctx.measureText(seg.t).width; } if (wi < wordsArr.length - 1) wpx += spaceW; }); ctx.restore(); const wu = Math.min(wpx / S.scale, 52); const left = (t.align === "center") ? t.at[0] - wu / 2 : t.at[0]; const overlap = Math.min(hx + hw, left + wu) - Math.max(hx, left); if (overlap < Math.min(hw, wu) * 0.4) continue; const score = dy + Math.abs(left - hx) * 0.15; if (!best || score < best.score) best = { x: left, w: wu, score }; } return best; } // ---------- canvas plumbing ---------- function fitCanvases() { const r = S.wrap.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); [S.agentCv, S.liveCv, S.userCv].forEach(cv => { cv.width = Math.round(r.width * dpr); cv.height = Math.round(r.height * dpr); cv.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0); }); S.scale = Math.min(r.width / LOGICAL_W, r.height / LOGICAL_H); S.offX = (r.width - LOGICAL_W * S.scale) / 2; S.offY = (r.height - LOGICAL_H * S.scale) / 2; redrawBaked(); redrawUser(); } function redrawBaked() { const ctx = S.agentCv.getContext("2d"); ctx.clearRect(0, 0, S.agentCv.width, S.agentCv.height); for (const item of S.bakedOps) renderOp(ctx, item, 1); } function redrawUser() { const ctx = S.userCv.getContext("2d"); ctx.clearRect(0, 0, S.userCv.width, S.userCv.height); for (const st of S.userStrokes) { ctx.save(); if (st.eraser) ctx.globalCompositeOperation = "destination-out"; strokePts(ctx, st.pts.map(px), 1, st.eraser ? "#000" : st.color, lw(st.eraser ? 2.4 : st.size)); ctx.restore(); } } function bake(item) { S.bakedOps.push(item); renderOp(S.agentCv.getContext("2d"), item, 1); } function clearAgent() { S.bakedOps = []; S.agentCv.getContext("2d").clearRect(0, 0, S.agentCv.width, S.agentCv.height); S.liveCv.getContext("2d").clearRect(0, 0, S.liveCv.width, S.liveCv.height); } // ---------- audio ---------- function actx() { if (!S.actx) S.actx = new (window.AudioContext || window.webkitAudioContext)(); if (S.actx.state === "suspended") S.actx.resume(); return S.actx; } function b64ToBuf(b64) { const bin = atob(b64), arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr.buffer; } // ---------- lesson playback ---------- async function playStep(step) { const ops = (step.board || []).map(o => ({ op: o, seed: (S.opSeedCounter++ * 2654435761) >>> 0 })); let dur = step.dur || Math.max(2.2, String(step.say || "").split(/\s+/).length * 0.36); let src = null; let audioEnded = true; // true while there is no source; set false when one starts if (step.audio && S.voiceOn) { try { // audio was pre-decoded the moment the step arrived (see enqueue) const buf = step._bufP ? await step._bufP : await actx().decodeAudioData(b64ToBuf(step.audio)); if (buf) { dur = buf.duration; src = actx().createBufferSource(); src.buffer = buf; src.connect(actx().destination); audioEnded = false; src.onended = () => { audioEnded = true; }; // the ONLY reliable end signal } } catch (e) { src = null; } } setCaption(step.say || ""); // a leading "clear" op wipes the board instantly, the rest animate if (ops.length && ops[0].op.op === "clear") clearAgent(); const drawable = ops.filter(x => x.op.op !== "clear"); const total = drawable.reduce((s, x) => s + opCost(x.op), 0) || 1; let t0 = 0; const budget = Math.max(1.2, dur * 0.92); const sched = drawable.map(x => { const span = Math.max(0.22, (opCost(x.op) / total) * budget); const item = { ...x, start: t0, end: t0 + span }; t0 += span; return item; }); const startAt = performance.now(); if (src) src.start(); await new Promise(resolve => { let bakedIdx = 0; const tick = () => { const el = (performance.now() - startAt) / 1000; const live = S.liveCv.getContext("2d"); live.clearRect(0, 0, S.liveCv.width, S.liveCv.height); for (let i = bakedIdx; i < sched.length; i++) { const it = sched[i]; if (el >= it.end) { bake(it); bakedIdx = i + 1; continue; } if (el >= it.start) { renderOp(live, it, (el - it.start) / (it.end - it.start)); } } // wait for the audio's real `ended` event — clock estimates start the // next clip while this one's tail is still audible (voices overlap). // el > dur + 2 is only a safety net if `ended` never fires. const audioDone = audioEnded || el > dur + 2.0; if (bakedIdx >= sched.length && audioDone) { live.clearRect(0, 0, S.liveCv.width, S.liveCv.height); setTimeout(resolve, 140); // natural breath between steps } else if (S.stopFlag) { try { src && src.stop(); } catch (e) {} if (S.curTurn !== S.killTurn) { // bake the remainder instantly so the board ends complete — // but NOT when the turn was abandoned via "New lesson" for (let i = bakedIdx; i < sched.length; i++) bake(sched[i]); } live.clearRect(0, 0, S.liveCv.width, S.liveCv.height); resolve(); } else { S.raf = requestAnimationFrame(tick); } }; S.raf = requestAnimationFrame(tick); }); } async function pump() { if (S.playing) return; if (S.stopFlag) { setTimeout(pump, 90); return; } // wait out the stop window S.playing = true; setTalking(true); while (S.queue.length) { if (S.stopFlag) break; const step = S.queue.shift(); await playStep(step); } S.playing = false; setTalking(false); if (S.queue.length) setTimeout(pump, 120); // interrupted mid-queue: retry else setCaption(""); } // ---------- public API ---------- function setBoardBig(on) { document.body.classList.toggle("board-big", !!on); const row = document.querySelector(".board-col")?.parentElement; if (row) row.classList.toggle("board-big", !!on); } function keepBoardInView() { if (Date.now() > S.keepBoardUntil) return; const shell = document.querySelector(".board-shell"); const actions = document.querySelector(".board-actions"); if (!shell || !actions) return; const r = shell.getBoundingClientRect(); const ar = actions.getBoundingClientRect(); // Only fight automatic scroll when the board can actually fit. if (r.height + ar.height + 48 > window.innerHeight) return; if (r.top < 8 || ar.bottom > window.innerHeight - 8) { window.scrollTo({ top: Math.max(0, window.scrollY + r.top - 8), left: 0, behavior: "auto" }); } } window.addEventListener("scroll", () => { if (Date.now() > S.keepBoardUntil) return; clearTimeout(S.scrollTid); S.scrollTid = setTimeout(keepBoardInView, 45); }, { passive: true }); window.tutoriOnPayload = (raw) => { if (!raw) return; let p; try { p = typeof raw === "string" ? JSON.parse(raw) : raw; } catch (e) { return; } if (!S.mounted) { setTimeout(() => window.tutoriOnPayload(raw), 300); return; } if (p.turn && p.turn === S.killTurn) return; // cancelled via "New lesson" if (p.turn && p.turn !== S.curTurn) { // a new turn begins S.curTurn = p.turn; S.enqueued = 0; S.stopFlag = true; // fast-forward whatever is playing setTimeout(() => { S.stopFlag = false; }, 50); S.queue = []; // Gradio may focus the transcript/input after submit, which scrolls the // board header out of view right as drawing begins. Keep the whole board // visible for the teaching part of the turn. S.keepBoardUntil = Date.now() + 75000; requestAnimationFrame(keepBoardInView); } S.voiceOn = p.voice !== false; // the agent asks for a bigger stage when the lesson is drawing-heavy if (p.big !== undefined) setBoardBig(!!p.big); if (p.status_detail !== undefined) setStatus(p.status_detail, p.status); for (const s of (p.steps || [])) { // incremental: each step carries its index const idx = (s.i !== undefined) ? s.i : S.enqueued; if (idx >= S.enqueued) { if (s.audio && S.voiceOn) { // decode now, while earlier steps play try { s._bufP = actx().decodeAudioData(b64ToBuf(s.audio)).catch(() => null); } catch (e) { s._bufP = null; } } S.queue.push(s); S.enqueued = idx + 1; } } if (S.queue.length) setTimeout(pump, 30); if (p.status === "done" || p.status === "error") { setTimeout(() => setStatus("", "idle"), p.status === "error" ? 6000 : 1500); } }; window.tutoriSnapshot = () => { if (!S.mounted) return ""; const r = S.wrap.getBoundingClientRect(); const out = document.createElement("canvas"); const scl = Math.min(1, 1100 / r.width); out.width = Math.round(r.width * scl); out.height = Math.round(r.height * scl); const ctx = out.getContext("2d"); ctx.fillStyle = "#FBF8F1"; ctx.fillRect(0, 0, out.width, out.height); [S.agentCv, S.liveCv, S.userCv].forEach(cv => ctx.drawImage(cv, 0, 0, cv.width, cv.height, 0, 0, out.width, out.height)); return out.toDataURL("image/png"); }; window.tutoriStop = () => { S.stopFlag = true; S.queue = []; setTimeout(() => { S.stopFlag = false; }, 60); }; // Snapshot only when the learner has actually drawn something (saves vision tokens). window.tutoriSnapshotIfInk = () => (S.mounted && S.userStrokes.length ? window.tutoriSnapshot() : ""); window.tutoriClearAll = () => { setBoardBig(false); if (!S.mounted) return; S.killTurn = S.curTurn; // silence stragglers + suppress the stop-bake window.tutoriStop(); S.userStrokes = []; redrawUser(); clearAgent(); setCaption(""); setStatus("", "idle"); setTimeout(clearAgent, 280); // belt-and-braces against repaint races }; // ---------- UI chrome ---------- function setCaption(text) { const el = document.getElementById("tutori-caption"); if (!el) return; el.textContent = text; el.classList.toggle("show", !!text); } function setStatus(text, mode) { const el = document.getElementById("tutori-status"); if (!el) return; el.textContent = text || ""; el.dataset.mode = mode || "idle"; el.classList.toggle("show", !!text); } function setTalking(on) { const dot = document.getElementById("tutori-talking"); if (dot) dot.classList.toggle("on", on); } // ---------- user pen ---------- function toLogical(ev) { const r = S.userCv.getBoundingClientRect(); return [ ((ev.clientX - r.left) - S.offX) / S.scale, ((ev.clientY - r.top) - S.offY) / S.scale, ]; } function bindPen() { let cur = null; S.userCv.addEventListener("pointerdown", ev => { actx(); S.userCv.setPointerCapture(ev.pointerId); cur = { color: S.tool.color, size: S.tool.size, eraser: S.tool.mode === "eraser", pts: [toLogical(ev)], }; S.userStrokes.push(cur); }); S.userCv.addEventListener("pointermove", ev => { if (!cur) return; cur.pts.push(toLogical(ev)); redrawUser(); }); const up = () => { cur = null; }; S.userCv.addEventListener("pointerup", up); S.userCv.addEventListener("pointercancel", up); } const PEN_COLORS = ["#2563eb", "#dc2626", "#15803d", "#27272a", "#7c3aed"]; function toolbarHTML() { const sw = PEN_COLORS.map(c => ``).join(""); return `
${sw}
Tutori
`; } function bindToolbar(bar) { bar.querySelectorAll(".tb-swatch").forEach(b => { b.addEventListener("click", () => { S.tool.mode = "pen"; S.tool.color = b.dataset.color; bar.querySelectorAll(".tb-swatch, .tb-btn").forEach(x => x.classList.remove("active")); b.classList.add("active"); }); }); bar.querySelector('[data-color="#2563eb"]').classList.add("active"); bar.querySelector("#tb-eraser").addEventListener("click", (e) => { S.tool.mode = "eraser"; bar.querySelectorAll(".tb-swatch, .tb-btn").forEach(x => x.classList.remove("active")); e.currentTarget.classList.add("active"); }); bar.querySelector("#tb-undo").addEventListener("click", () => { S.userStrokes.pop(); redrawUser(); }); bar.querySelector("#tb-clear-mine").addEventListener("click", () => { S.userStrokes = []; redrawUser(); }); bar.querySelector("#tb-clear-all").addEventListener("click", () => { S.userStrokes = []; redrawUser(); clearAgent(); setCaption(""); }); } // ---------- mount ---------- function mount() { const host = document.getElementById("tutori-board-mount"); if (!host || S.mounted) return; S.mounted = true; host.innerHTML = `
${toolbarHTML()}
`; S.wrap = host.querySelector("#tutori-wrap"); S.agentCv = host.querySelector("#cv-agent"); S.liveCv = host.querySelector("#cv-live"); S.userCv = host.querySelector("#cv-user"); bindToolbar(host.querySelector("#tutori-toolbar")); bindPen(); fitCanvases(); new ResizeObserver(fitCanvases).observe(S.wrap); document.addEventListener("pointerdown", () => actx(), { once: true, capture: true }); } // Gradio renders the DOM asynchronously; retry until the mount node exists. const tryMount = setInterval(() => { mount(); if (S.mounted) clearInterval(tryMount); }, 200); if (document.readyState !== "loading") setTimeout(mount, 0); // study-coach chips: pop animation when their labels change function watchChips() { const row = document.querySelector(".chip-row"); if (!row) { setTimeout(watchChips, 600); return; } // tag accordion blocks (gradio strips :has() from custom css) — and keep // tagging as gradio mounts/replaces them const tagFolders = () => { document.querySelectorAll(".side-col button.label-wrap").forEach(b => { const blk = b.closest(".block"); if (blk) blk.classList.add("paper-folder"); }); }; tagFolders(); new MutationObserver(tagFolders).observe(document.body, { childList: true, subtree: true }); row.querySelectorAll("button").forEach(btn => { new MutationObserver(() => { btn.classList.remove("chip-pop"); void btn.offsetWidth; // restart the animation btn.classList.add("chip-pop"); }).observe(btn, { childList: true, characterData: true, subtree: true }); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", watchChips); } else { watchChips(); } })();