Spaces:
Running on Zero
Running on Zero
| /* ============================================================ | |
| 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 => | |
| `<button class="tb-swatch" data-color="${c}" style="--c:${c}" title="Pen ${c}"></button>`).join(""); | |
| return ` | |
| <div class="tb-group">${sw}</div> | |
| <div class="tb-group"> | |
| <button class="tb-btn" id="tb-eraser" title="Eraser">⌫</button> | |
| <button class="tb-btn" id="tb-undo" title="Undo my stroke">↩</button> | |
| <button class="tb-btn" id="tb-clear-mine" title="Clear my ink">✕ mine</button> | |
| <button class="tb-btn" id="tb-clear-all" title="Clear whole board">✕ all</button> | |
| </div> | |
| <div class="tb-spacer"></div> | |
| <div class="tb-talk"><span id="tutori-talking" class="talk-dot"></span><span class="talk-label">Tutori</span></div>`; | |
| } | |
| 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 = ` | |
| <div class="board-shell"> | |
| <div class="board-toolbar" id="tutori-toolbar">${toolbarHTML()}</div> | |
| <div class="board-wrap" id="tutori-wrap"> | |
| <canvas id="cv-agent"></canvas> | |
| <canvas id="cv-live"></canvas> | |
| <canvas id="cv-user"></canvas> | |
| <div class="board-status" id="tutori-status"></div> | |
| </div> | |
| <div class="board-caption" id="tutori-caption"></div> | |
| </div>`; | |
| 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(); } | |
| })(); | |