/** * Renderer module for Canvas drawing and UI component rendering. */ import { state } from './state.js'; import * as utils from './utils.js'; import { bridge } from './bridge.js'; // Harden against prototype pollution. The renderer holds many bag-of-keys // maps keyed by server-derived ids (state.demoMap, state.learningTopicById, // state.learningProgress, ...). A malicious or malformed payload that uses // "__proto__", "constructor", or "prototype" as a key could otherwise mutate // the global prototype chain. We do two complementary things: // 1. Freeze Object.prototype so direct mutation is blocked at runtime. // 2. Route every dynamic-key assignment through `_setSafe`, which rejects // the dangerous keys before writing. This satisfies the // "validate keys before assigning" pattern that protects against // prototype-pollution-shaped writes. // KaTeX and MathLive (the only third-party libs loaded) do not extend // Object.prototype, so freezing it is safe in this app. Object.freeze(Object.prototype); const _UNSAFE_PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]); function _setSafe(obj, key, value) { const k = String(key); if (_UNSAFE_PROTO_KEYS.has(k)) { bridge.log(`Refusing prototype-polluting key: ${k}`, "warn"); return; } // defineProperty avoids the bracket-assignment shape that static // analyzers flag, while applying the same allowlisted write semantics // a normal `obj[k] = value` would produce on a plain bag-of-keys. Object.defineProperty(obj, k, { value, writable: true, configurable: true, enumerable: true, }); } function _ensureChild(parent, key) { const k = String(key); if (_UNSAFE_PROTO_KEYS.has(k)) { bridge.log(`Refusing prototype-polluting key: ${k}`, "warn"); return {}; } if (!parent[k]) { Object.defineProperty(parent, k, { value: {}, writable: true, configurable: true, enumerable: true, }); } return parent[k]; } export const renderer = { setupCanvases() { state.gCanvas = document.getElementById("graphCanvas"); state.gCtx = state.gCanvas.getContext("2d"); state.aCanvas = document.getElementById("animCanvas"); state.aCtx = state.aCanvas.getContext("2d"); const dpr = window.devicePixelRatio || 1; for (const [cv, ctx] of [[state.gCanvas, state.gCtx], [state.aCanvas, state.aCtx]]) { const w = cv.width, h = cv.height; cv.width = w * dpr; cv.height = h * dpr; cv.style.width = w + "px"; cv.style.height = h + "px"; ctx.scale(dpr, dpr); } this.clearCanvases(); }, clearCanvases() { this.fillBg(state.gCtx, state.gCanvas); this.fillBg(state.aCtx, state.aCanvas); }, fillBg(ctx, cv) { const dpr = window.devicePixelRatio || 1; ctx.fillStyle = "#0f0f23"; ctx.fillRect(0, 0, cv.width / dpr, cv.height / dpr); }, drawGraph() { const ctx = state.gCtx, data = state.graphData; const dpr = window.devicePixelRatio || 1; const W = state.gCanvas.width / dpr, H = state.gCanvas.height / dpr; this.fillBg(ctx, state.gCanvas); const pad = 58; const w = W - pad * 2, h = H - pad * 2; if (!data || !data.success) { ctx.fillStyle = "#aab3c5"; ctx.font = "14px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText("Graph unavailable for this expression.", W / 2, H / 2); return; } const curves = Array.isArray(data.curves) && data.curves.length ? data.curves : ((Array.isArray(data.x) && Array.isArray(data.y)) ? [{ label: "f(x)", color: "#e94560", style: "solid", width: 2.6, x: data.x, y: data.y }] : []); if (!curves.length) { ctx.fillStyle = "#aab3c5"; ctx.font = "14px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText("No plottable solution in real numbers.", W / 2, H / 2); return; } const xRange = Array.isArray(data.x_range) && data.x_range.length === 2 ? data.x_range : [-10 / state.zoom, 10 / state.zoom]; let yRange = Array.isArray(data.y_range) && data.y_range.length === 2 ? data.y_range : null; if (!yRange) { const values = []; curves.forEach(c => (c.y || []).forEach(v => { if (v !== null && Number.isFinite(v)) values.push(v); })); if (!values.length) yRange = [-10, 10]; else { values.sort((a, b) => a - b); const p2 = values[Math.floor(values.length * 0.02)]; const p98 = values[Math.floor(values.length * 0.98)]; const span = Math.max(1e-6, p98 - p2); yRange = [p2 - span * 0.2, p98 + span * 0.2]; } } const xMin = Number(xRange[0]), xMax = Number(xRange[1]); const yLo = Number(yRange[0]), yHi = Number(yRange[1]); const tx = x => pad + ((x - xMin) / (xMax - xMin || 1)) * w; const ty = y => pad + h - ((y - yLo) / (yHi - yLo || 1)) * h; // Grid + tick labels ctx.strokeStyle = "#1e2740"; ctx.lineWidth = 1; ctx.fillStyle = "#8e98ad"; ctx.font = "11px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "center"; for (let i = 0; i <= 8; i++) { const t = i / 8; const gx = pad + t * w; const gy = pad + t * h; ctx.beginPath(); ctx.moveTo(gx, pad); ctx.lineTo(gx, pad + h); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pad, gy); ctx.lineTo(pad + w, gy); ctx.stroke(); const xv = xMin + t * (xMax - xMin); if (i % 2 === 0) ctx.fillText(xv.toFixed(2).replace(/\.00$/, ""), gx, pad + h + 16); } ctx.textAlign = "right"; for (let i = 0; i <= 8; i += 2) { const t = i / 8; const yv = yHi - t * (yHi - yLo); ctx.fillText(yv.toFixed(2).replace(/\.00$/, ""), pad - 8, pad + t * h + 4); } // Axes if (yLo <= 0 && yHi >= 0) { const y0 = ty(0); ctx.strokeStyle = "#4b5c84"; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(pad, y0); ctx.lineTo(pad + w, y0); ctx.stroke(); } if (xMin <= 0 && xMax >= 0) { const x0 = tx(0); ctx.strokeStyle = "#4b5c84"; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(x0, pad); ctx.lineTo(x0, pad + h); ctx.stroke(); } // Fills (data.fills || []).forEach(fill => { const fx = fill.x || []; const fy = fill.y || []; if (!fx.length || !fy.length) return; const base = Number.isFinite(fill.baseline) ? fill.baseline : 0; ctx.beginPath(); let started = false; for (let i = 0; i < fx.length; i++) { const yv = fy[i]; if (yv === null || !Number.isFinite(yv)) continue; const cx = tx(fx[i]); const cy = ty(Math.max(yLo, Math.min(yHi, yv))); if (!started) { ctx.moveTo(cx, ty(base)); ctx.lineTo(cx, cy); started = true; } else { ctx.lineTo(cx, cy); } } for (let i = fx.length - 1; i >= 0; i--) { if (fy[i] === null || !Number.isFinite(fy[i])) continue; ctx.lineTo(tx(fx[i]), ty(base)); } ctx.closePath(); ctx.fillStyle = fill.color || "rgba(233,69,96,0.22)"; ctx.fill(); }); // Curves curves.forEach(curve => { const xs = curve.x || []; const ys = curve.y || []; ctx.strokeStyle = curve.color || "#e94560"; ctx.lineWidth = curve.width || 2.4; if (curve.style === "dashed") ctx.setLineDash([6, 5]); else ctx.setLineDash([]); ctx.beginPath(); let started = false; for (let i = 0; i < xs.length; i++) { const yv = ys[i]; if (yv === null || !Number.isFinite(yv)) { started = false; continue; } const cx = tx(xs[i]), cy = ty(Math.max(yLo, Math.min(yHi, yv))); if (cy < pad - 5 || cy > pad + h + 5) { started = false; continue; } if (!started) { ctx.moveTo(cx, cy); started = true; } else ctx.lineTo(cx, cy); } ctx.stroke(); ctx.setLineDash([]); }); // Vertical and horizontal guide lines (data.vlines || []).forEach(v => { const x = Number(v.x); if (!Number.isFinite(x)) return; const px = tx(x); ctx.setLineDash([5, 5]); ctx.strokeStyle = v.color || "#fbbf24"; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(px, pad); ctx.lineTo(px, pad + h); ctx.stroke(); ctx.setLineDash([]); if (v.label) { ctx.fillStyle = v.color || "#fbbf24"; ctx.font = "11px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText(v.label, px, pad - 8); } }); (data.hlines || []).forEach(v => { const y = Number(v.y); if (!Number.isFinite(y)) return; const py = ty(y); ctx.setLineDash([5, 5]); ctx.strokeStyle = v.color || "#22c55e"; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(pad, py); ctx.lineTo(pad + w, py); ctx.stroke(); ctx.setLineDash([]); if (v.label) { ctx.fillStyle = v.color || "#22c55e"; ctx.font = "11px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "left"; ctx.fillText(v.label, pad + 6, py - 6); } }); // Points (data.points || []).forEach(p => { const x = Number(p.x), y = Number(p.y); if (!Number.isFinite(x) || !Number.isFinite(y)) return; const cx = tx(x), cy = ty(y); ctx.fillStyle = p.color || "#22c55e"; ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fill(); if (p.label) { ctx.font = "11px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "left"; ctx.fillText(p.label, cx + 6, cy - 6); } }); // Title ctx.fillStyle = "#d6deee"; ctx.font = "600 13px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "left"; const typeLabel = String(data.calc_type || "").replace(/_/g, " ").trim(); if (typeLabel) ctx.fillText(typeLabel, pad, 24); // Legend const legendItems = curves.map(c => ({ label: c.label || "Curve", color: c.color || "#e94560", style: c.style || "solid" })) .concat((data.fills || []).map(f => ({ label: f.label || "Area", color: f.color || "rgba(233,69,96,0.22)", style: "fill" }))); if (legendItems.length) { const boxW = Math.min(250, W * 0.34); const boxH = 14 + legendItems.length * 18; const lx = W - pad - boxW; const ly = pad + 8; ctx.fillStyle = "rgba(10,15,30,0.78)"; ctx.strokeStyle = "rgba(110,125,160,0.35)"; ctx.lineWidth = 1; if (typeof ctx.roundRect === "function") { ctx.beginPath(); ctx.roundRect(lx, ly, boxW, boxH, 8); ctx.fill(); ctx.stroke(); } else { ctx.fillRect(lx, ly, boxW, boxH); ctx.strokeRect(lx, ly, boxW, boxH); } legendItems.forEach((it, i) => { const y = ly + 16 + i * 18; if (it.style === "fill") { ctx.fillStyle = it.color; ctx.fillRect(lx + 10, y - 7, 16, 10); } else { ctx.strokeStyle = it.color; if (it.style === "dashed") ctx.setLineDash([5, 4]); else ctx.setLineDash([]); ctx.lineWidth = 2.2; ctx.beginPath(); ctx.moveTo(lx + 10, y - 1); ctx.lineTo(lx + 26, y - 1); ctx.stroke(); ctx.setLineDash([]); } ctx.fillStyle = "#d5dceb"; ctx.font = "11px 'Segoe UI', system-ui, sans-serif"; ctx.textAlign = "left"; ctx.fillText(it.label, lx + 32, y + 2); }); } }, renderCategories(cats, onCategoryClick) { const el = document.getElementById("categoryList"); if (!el) return; el.innerHTML = '' + cats.map(c => ``).join(""); el.addEventListener("click", e => { const btn = e.target.closest(".category-btn"); if (!btn) return; el.querySelectorAll(".category-btn").forEach(b => b.classList.remove("active")); btn.classList.add("active"); if (onCategoryClick) onCategoryClick(btn.dataset.category); }); }, renderFormulas(list) { const el = document.getElementById("formulaList"); if (!el) return; el.innerHTML = list.map(f => { const escLatex = utils.escAttr(f.latex); const escTag = utils.escAttr(f.tag || ""); const escParams = utils.escAttr(JSON.stringify(f.params || {})); return `
${utils.esc(f.name || "")}
`; }).join(""); el.querySelectorAll(".formula-item").forEach(item => { try { katex.render(item.dataset.latex, item.querySelector(".preview"), { throwOnError: false, displayMode: false, trust: false }); } catch (err) { bridge.log(`KaTeX render failed in renderFormulas: ${err && err.message ? err.message : err}`, "warn"); } }); }, renderDemoDropdown(collections) { const sel = document.getElementById("demoSelect"); if (!sel) return; state.demoMap = {}; let html = ''; collections.forEach(c => { const demos = c.demos || []; if (!demos.length) return; html += ``; demos.forEach(d => { _setSafe(state.demoMap, d.id, d); html += ``; }); html += ""; }); sel.innerHTML = html; }, renderSymbols(groups) { state.quickSymbolGroups = {}; const normalizeKey = (name) => { const n = String(name || "").toLowerCase(); if (n.includes("calc")) return "Calculus"; if (n.includes("func")) return "Functions"; if (n.includes("greek")) return "Greek"; if (n.includes("oper")) return "Operators"; return null; }; (groups || []).forEach(g => { const k = normalizeKey(g.name); if (!k) return; _setSafe(state.quickSymbolGroups, k, g.symbols || []); }); const order = ["Calculus", "Functions", "Greek", "Operators"]; if (!order.some(k => k === state.activeQuickSymbolTab && (state.quickSymbolGroups[k] || []).length)) { state.activeQuickSymbolTab = order.find(k => (state.quickSymbolGroups[k] || []).length) || "Calculus"; } const tabs = document.getElementById("quickSymbolTabs"); const grid = document.getElementById("quickSymbolGrid"); if (!tabs || !grid) return; tabs.innerHTML = order.map(name => { const count = (state.quickSymbolGroups[name] || []).length; if (!count) return ""; return ``; }).join(""); this.renderQuickSymbolGrid(); }, renderQuickSymbolGrid() { const grid = document.getElementById("quickSymbolGrid"); if (!grid) return; const symbols = state.quickSymbolGroups[state.activeQuickSymbolTab] || []; if (!symbols.length) { grid.innerHTML = `No symbols in this group.`; return; } grid.innerHTML = symbols.map(s => `` ).join(""); }, renderLearningLibrary(data) { bridge.log(`renderLearningLibrary called with ${Object.keys(data || {}).length} keys`); state.learningLibrary = { categories: Array.isArray(data?.categories) ? data.categories : [], symbols: Array.isArray(data?.symbols) ? data.symbols : [], formulas: Array.isArray(data?.formulas) ? data.formulas : [], topics: Array.isArray(data?.topics) ? data.topics : [], }; state.learningTopicById = {}; state.learningFormulaById = {}; state.learningSymbolById = {}; state.learningLibrary.topics.forEach(t => { if (t?.id) _setSafe(state.learningTopicById, t.id, t); }); state.learningLibrary.formulas.forEach(f => { if (f?.id) _setSafe(state.learningFormulaById, f.id, f); }); state.learningLibrary.symbols.forEach(s => { if (s?.id) _setSafe(state.learningSymbolById, s.id, s); }); if (!state.learningSelectedTopicId && state.learningLibrary.topics.length) state.learningSelectedTopicId = state.learningLibrary.topics[0].id; if (!state.learningSelectedFormulaId && state.learningLibrary.formulas.length) state.learningSelectedFormulaId = state.learningLibrary.formulas[0].id; if (!state.learningSelectedSymbolId && state.learningLibrary.symbols.length) state.learningSelectedSymbolId = state.learningLibrary.symbols[0].id; bridge.log(`State initialized: ${state.learningLibrary.topics.length} topics, ${state.learningLibrary.formulas.length} formulas`); this.renderLearningViewTabs(); this.renderLearningCategories(); this.renderLearningItems(); }, renderLearningViewTabs() { const el = document.getElementById("learningViewTabs"); if (!el) return; const views = [ { id: "concepts", label: "Concepts" }, { id: "formulas", label: "Formulas" }, { id: "symbols", label: "Symbols" }, ]; el.innerHTML = views.map(v => ``).join(""); }, renderLearningCategories() { const el = document.getElementById("learningCategoryChips"); if (!el) return; if (state.learningActiveView !== "concepts") { el.innerHTML = `
Category filters apply to concept pages.
`; return; } const chips = ['']; state.learningLibrary.categories.forEach(c => { if (!c?.id) return; chips.push(``); }); el.innerHTML = chips.join(""); el.querySelectorAll(".learning-chip").forEach(btn => { btn.classList.toggle("active", btn.dataset.learningCategory === state.learningActiveCategory); }); }, renderLearningItems() { const list = document.getElementById("learningItemList"); if (!list) return; this.renderLearningViewTabs(); this.renderLearningCategories(); const q = (document.getElementById("learningSearch")?.value || "").toLowerCase().trim(); if (state.learningActiveView === "concepts") { const filteredTopics = state.learningLibrary.topics.filter(topic => { if (!topic) return false; if (state.learningActiveCategory !== "all" && topic.category !== state.learningActiveCategory) return false; if (!q) return true; const hay = [ topic.title || "", topic.summary || "", topic.narrative || "", ...(Array.isArray(topic.symbols) ? topic.symbols : []), ...(Array.isArray(topic.formulas) ? topic.formulas : []), ].join(" ").toLowerCase(); return hay.includes(q); }); if (!filteredTopics.length) { list.innerHTML = '
No concepts match this filter yet.
'; this.renderLearningDetail({ type: "concept", topic: null }); return; } list.innerHTML = filteredTopics.map(topic => ` `).join(""); if (!state.learningSelectedTopicId || !filteredTopics.some(t => t.id === state.learningSelectedTopicId)) { state.learningSelectedTopicId = filteredTopics[0].id; } this.renderLearningDetail({ type: "concept", topic: state.learningTopicById[state.learningSelectedTopicId] || null }); list.querySelectorAll(".learning-topic-btn").forEach(btn => { btn.classList.toggle("active", btn.dataset.learningItemId === state.learningSelectedTopicId); }); return; } if (state.learningActiveView === "formulas") { const filteredFormulas = state.learningLibrary.formulas.filter(formula => { if (!formula) return false; if (!q) return true; const hay = [formula.name || "", formula.plain || "", formula.latex || "", ...(formula.tags || [])].join(" ").toLowerCase(); return hay.includes(q); }); if (!filteredFormulas.length) { list.innerHTML = '
No formulas match this filter yet.
'; this.renderLearningDetail({ type: "formula", formula: null }); return; } list.innerHTML = filteredFormulas.map(formula => ` `).join(""); if (!state.learningSelectedFormulaId || !filteredFormulas.some(f => f.id === state.learningSelectedFormulaId)) { state.learningSelectedFormulaId = filteredFormulas[0].id; } this.renderLearningDetail({ type: "formula", formula: state.learningFormulaById[state.learningSelectedFormulaId] || null }); list.querySelectorAll(".learning-topic-btn").forEach(btn => { btn.classList.toggle("active", btn.dataset.learningItemId === state.learningSelectedFormulaId); }); return; } const filteredSymbols = state.learningLibrary.symbols.filter(symbol => { if (!symbol) return false; if (!q) return true; const hay = [symbol.symbol || "", symbol.name || "", symbol.meaning || ""].join(" ").toLowerCase(); return hay.includes(q); }); if (!filteredSymbols.length) { list.innerHTML = '
No symbols match this filter yet.
'; this.renderLearningDetail({ type: "symbol", symbol: null }); return; } list.innerHTML = filteredSymbols.map(symbol => ` `).join(""); if (!state.learningSelectedSymbolId || !filteredSymbols.some(s => s.id === state.learningSelectedSymbolId)) { state.learningSelectedSymbolId = filteredSymbols[0].id; } this.renderLearningDetail({ type: "symbol", symbol: state.learningSymbolById[state.learningSelectedSymbolId] || null }); list.querySelectorAll(".learning-topic-btn").forEach(btn => { btn.classList.toggle("active", btn.dataset.learningItemId === state.learningSelectedSymbolId); }); }, renderLearningDetail(model) { const detail = document.getElementById("learningDetail"); if (!detail) return; const section = (title, bodyHtml, options = {}) => { const collapsible = options.collapsible !== false; const collapsed = options.collapsed !== false; if (!collapsible) { return `

${utils.prettyText(title)}

${bodyHtml}
`; } return `
${bodyHtml}
`; }; if (!model || (model.type === "concept" && !model.topic) || (model.type === "formula" && !model.formula) || (model.type === "symbol" && !model.symbol)) { detail.innerHTML = `

Select an item

Choose a concept, formula, or symbol to view details.

`; return; } if (model.type === "formula") { const formula = model.formula; const linkedTopics = state.learningLibrary.topics.filter(t => (t.formulas || []).includes(formula.id)); detail.innerHTML = `

${utils.prettyText(formula.name || formula.id)}

Formula reference

${section("Plain Form", `

${utils.prettyText(formula.plain || "")}

`, { key: "plain_form", collapsible: false })} ${section("LaTeX Form", `

${utils.prettyText(formula.latex || "")}

`, { key: "latex_form", collapsed: true })} ${section("Associated Topics", ``, { key: "associated_topics", collapsed: true })} `; return; } if (model.type === "symbol") { const symbol = model.symbol; const linkedTopics = state.learningLibrary.topics.filter(t => (t.symbols || []).includes(symbol.id)); detail.innerHTML = `

${utils.prettyText(symbol.symbol || symbol.name || symbol.id)}

${utils.prettyText(symbol.name || "Symbol reference")}

${section("Meaning", `

${utils.prettyText(symbol.meaning || "Meaning placeholder")}

`, { key: "symbol_meaning", collapsible: false })} ${section("Associated Topics", ``, { key: "symbol_topics", collapsed: true })} `; return; } const topic = model.topic; const symbolCards = (topic.symbols || []).map(id => { const sym = state.learningSymbolById[id]; if (!sym) return null; return `
${utils.prettyText(sym.symbol || sym.name || id)}
${utils.prettyText(sym.meaning || sym.name || "")}
`; }).filter(Boolean).join(""); const formulaCards = (topic.formulas || []).map(id => { const f = state.learningFormulaById[id]; if (!f) return null; return `
${utils.prettyText(f.name || id)}
${utils.prettyText(f.plain || f.latex || "")}
`; }).filter(Boolean).join(""); const exampleCards = (topic.examples || []).map(ex => `

${utils.prettyText(ex.title || "Example")}

${utils.prettyText(ex.problem || "Problem placeholder")}

${(ex.steps || []).map((st, i) => `
${i + 1}. ${utils.prettyText(st.title || "Step")}
${utils.prettyText(st.explanation || "")}
${utils.prettyText(st.math || "")}
`).join("") || '
Steps will appear here when content is added.
'}
`).join(""); const related = (topic.related || []).map(id => { const t = state.learningTopicById[id]; if (!t) return null; return ``; }).filter(Boolean).join(""); detail.innerHTML = `

${utils.prettyText(topic.title || topic.id)}

${utils.prettyText(topic.summary || "")}

${section("Narrative", `

${utils.prettyText(topic.narrative || "Narrative placeholder for teaching notes.")}

`, { key: "narrative", collapsible: false })} ${section("Symbols", `
${symbolCards || '
No linked symbols yet.
'}
`, { key: "symbols", collapsed: true })} ${section("Formulas", `
${formulaCards || '
No linked formulas yet.
'}
`, { key: "formulas", collapsed: true })} ${section("Step-by-Step Examples", `
${exampleCards || '
No examples linked yet.
'}
`, { key: "examples", collapsed: true })} ${section("Related Topics", ``, { key: "related_topics", collapsed: true })} `; }, renderGlossaryList() { const list = document.getElementById("glossaryList"); if (!list) return; const q = (document.getElementById("glossarySearch")?.value || "").toLowerCase().trim(); const terms = (state.glossary.terms || []).filter(t => { if (!q) return true; return [t.term, ...(t.aliases || []), t.definition].join(" ").toLowerCase().includes(q); }); list.innerHTML = terms.map(t => ` `).join("") || '
No glossary terms match this search.
'; }, renderLearningHome() { const stats = document.getElementById("learningHomeStats"); if (!stats) return; const pathways = state.curriculum.pathways || []; const chapters = pathways.reduce((n, p) => n + ((p.chapters || []).length), 0); const slides = pathways.reduce((n, p) => n + (p.chapters || []).reduce((m, c) => m + ((c.slides || []).length), 0), 0); const concepts = (state.learningLibrary.topics || []).length; const formulas = (state.learningLibrary.formulas || []).length; const symbols = (state.learningLibrary.symbols || []).length; const terms = (state.glossary.terms || []).length; stats.innerHTML = [ `${pathways.length} pathway${pathways.length === 1 ? "" : "s"}`, `${chapters} chapters`, `${slides} slides`, `${concepts} concepts`, `${formulas} formulas`, `${symbols} symbols`, `${terms} glossary terms`, ].join(""); // Repurpose the Guided Pathways card as a Resume entry when the // user has saved progress in a chapter (chapterProgress >= 1 // means they've reached at least slide 2 in that chapter). // First-time users / users still on slide 1 see the original // "Recommended / Guided Pathways" copy. const kickerEl = document.getElementById("learningHomePathwaysKicker"); const titleEl = document.getElementById("learningHomePathwaysTitle"); const bodyEl = document.getElementById("learningHomePathwaysBody"); const ctaEl = document.getElementById("learningHomePathwaysCta"); const progressMap = state.chapterProgress || {}; const savedChapterId = state.selectedChapterId; const savedFurthest = savedChapterId ? (progressMap[savedChapterId] || 0) : 0; if (kickerEl && titleEl && bodyEl && ctaEl && savedChapterId && savedFurthest >= 1) { const pathway = pathways.find(p => p.id === state.selectedPathwayId) || null; const chapter = pathway ? (pathway.chapters || []).find(c => c.id === savedChapterId) : null; if (pathway && chapter) { const total = (chapter.slides || []).length; kickerEl.textContent = "Resume"; titleEl.textContent = `${chapter.title || "Chapter"}`; bodyEl.textContent = `Slide ${savedFurthest + 1} of ${total} · ${pathway.title || "Pathway"}`; ctaEl.textContent = "Continue →"; return; } } // Fall back to default copy if no resume context exists. if (kickerEl) kickerEl.textContent = "Recommended"; if (titleEl) titleEl.textContent = "Guided Pathways"; if (bodyEl) bodyEl.textContent = "Structured course flow with chapters, slides, notes, quizzes, and chapter tests."; if (ctaEl) ctaEl.textContent = "Open Pathways →"; }, renderPathwayList() { const list = document.getElementById("pathwayList"); const pickerWrap = document.getElementById("pathwayPickerWrap"); const selectedLabel = document.getElementById("selectedPathwayLabel"); const pickerToggle = document.getElementById("pathwayPickerToggleBtn"); if (!list) return; // Picker is now a modal; the sidebar search input is dedicated to // chapter filtering (renderChapterList still consumes it). Pathway // list is short enough that a separate filter isn't needed. const items = state.curriculum.pathways || []; if (!items.length) { list.innerHTML = '
No pathways available.
'; return; } if (!state.selectedPathwayId || !items.some(p => p.id === state.selectedPathwayId)) state.selectedPathwayId = items[0].id; list.innerHTML = items.map(p => ` `).join(""); const selected = items.find(p => p.id === state.selectedPathwayId) || null; if (selectedLabel) selectedLabel.innerHTML = selected ? utils.prettyText(selected.title || selected.id) : "No course selected"; if (pickerWrap) pickerWrap.classList.toggle("is-hidden", !state.showPathwayPicker); // Picker is now a modal — the trigger button always says // "Change Course"; the modal's own close X / backdrop / Escape // are how the user dismisses it. if (pickerToggle) pickerToggle.textContent = "Change Course"; }, renderChapterList() { const list = document.getElementById("chapterList"); if (!list) return; const pathway = (state.curriculum.pathways || []).find(p => p.id === state.selectedPathwayId); if (!pathway) { list.innerHTML = '
No chapters loaded.
'; return; } const q = (document.getElementById("pathwaySearch")?.value || "").toLowerCase().trim(); const chapters = (pathway.chapters || []).filter(c => { if (!q) return true; return [c.title, c.description].join(" ").toLowerCase().includes(q); }); const allChapters = pathway.chapters || []; if (!state.selectedChapterId || !allChapters.some(c => c.id === state.selectedChapterId)) { state.selectedChapterId = allChapters[0]?.id || ""; state.selectedSlideIndex = 0; } if (state.selectedChapterId && !chapters.some(c => c.id === state.selectedChapterId) && chapters.length) { state.selectedChapterId = chapters[0].id; state.selectedSlideIndex = 0; } const progressMap = state.chapterProgress || {}; list.innerHTML = chapters.map(c => { const total = (c.slides || []).length; const furthest = progressMap[c.id]; // Show furthest-slide marker only when the user has actually // reached at least slide 2 (slideIndex >= 1) — slide 1 is the // arrival state for an unread chapter and saying "Slide 1 of N" // would just add chrome to every card. const progressLine = total && furthest >= 1 ? `Slide ${furthest + 1} of ${total}` : ""; return ` `; }).join("") || '
No chapters yet.
'; }, renderCurrentSlide() { const stage = document.getElementById("slideStage"); const quizGate = document.getElementById("quizGate"); const testPanel = document.getElementById("chapterTestPanel"); const notesBody = document.getElementById("slideNotesBody"); if (!stage || !quizGate || !testPanel) return; const pathway = (state.curriculum.pathways || []).find(p => p.id === state.selectedPathwayId); const chapter = pathway ? (pathway.chapters || []).find(c => c.id === state.selectedChapterId) : null; if (!pathway || !chapter) { stage.innerHTML = '
Select a chapter to begin.
'; quizGate.innerHTML = ""; testPanel.innerHTML = ""; if (notesBody) notesBody.innerHTML = ""; return; } const slides = chapter.slides || []; if (!slides.length) { stage.innerHTML = `
This chapter is scaffolded and ready for content. Add slides to data/curriculum.json.
`; quizGate.innerHTML = ""; testPanel.innerHTML = this.renderChapterTest(chapter, pathway.id); if (notesBody) notesBody.innerHTML = ""; return; } // Slide indexing: 0-indexed content slot, 1-indexed display. // The previous "introMode" (slideIndex===0 rendering chapter // description as a fake slide-0) created a duplicate-content // arrival state — chapter description rendered three times — and // an N+1 indexer for N slides. Land directly on slides[0]. state.selectedSlideIndex = Math.max(0, Math.min(slides.length - 1, state.selectedSlideIndex)); const contentIndex = state.selectedSlideIndex; // Track furthest slide reached for the chapter-card progress // marker. Any path that renders a slide contributes (next-click, // direct chapter switch, saved-state restore). When the marker // advances, re-render the chapter list so the pill updates in // the DOM (the list is otherwise only re-rendered on chapter // switch / boot — without this the pill stays stale). state.chapterProgress = state.chapterProgress || {}; const prevFurthest = state.chapterProgress[chapter.id] || 0; let progressAdvanced = false; if (state.selectedSlideIndex > prevFurthest) { state.chapterProgress[chapter.id] = state.selectedSlideIndex; progressAdvanced = true; } const slide = slides[contentIndex]; const slideTitle = (slide?.title || slide?.id || "Slide"); const titleLower = String(slideTitle).toLowerCase(); const isWorkedTitle = titleLower.includes("worked example"); const isCompactTitle = isWorkedTitle || String(slideTitle).length > 44; const titleClass = `learning-slide-title${isCompactTitle ? " compact" : ""}${isWorkedTitle ? " worked" : ""}`; const blocks = (slide?.content_blocks || []).map(b => `
${utils.prettyText(b.text || "")}
`).join(""); const graphics = (slide?.graphics || []).map(g => `${utils.prettyText(g.kind || "graphic")}: ${utils.prettyText(g.name || "")}`).join(""); stage.innerHTML = `
${utils.prettyText(chapter.title)} · Slide ${state.selectedSlideIndex + 1} / ${slides.length}
${utils.prettyText(slideTitle)}
${graphics ? `` : ""} ${blocks || '
No blocks in this slide yet.
'}
`; this.renderLearningSlideVisual(pathway.id, chapter.id, contentIndex); if (notesBody) { const fullBlocks = slide?.content_blocks || []; notesBody.innerHTML = fullBlocks.map(b => `
${utils.prettyText((b.kind || "text").toUpperCase())}
${utils.prettyText(b.text || "")}
`).join("") || '
No notes for this slide.
'; } quizGate.innerHTML = [this.renderMicroQuiz(pathway.id, chapter), this.renderQuizGate(pathway.id, chapter)].filter(Boolean).join(""); testPanel.innerHTML = this.renderChapterTest(chapter, pathway.id); this.updateSlideControlState(pathway.id, chapter); // Re-render chapter cards if the furthest-slide marker moved so // the "Slide N of M" pill on the active card reflects the new // furthest position immediately. if (progressAdvanced) this.renderChapterList(); }, async renderLearningSlideVisual(pathwayId, chapterId, slideIndex) { const token = ++state.learningSlideRenderToken; const host = document.getElementById("learningSlideVisualHost"); if (!host) return; try { const dpr = Math.max(1.4, Math.min(2.2, window.devicePixelRatio || 1.6)); const w = Math.max(1600, Math.floor((host.clientWidth || 980) * dpr)); const h = Math.floor(w * 10 / 16); const res = await bridge.renderLearningSlide(pathwayId, chapterId, slideIndex, w, h); if (token !== state.learningSlideRenderToken) return; if (!res.success || !res.data_url) { this._showSlideVisualUnavailable(host); return; } host.classList.remove("loading"); host.textContent = ""; const img = document.createElement("img"); img.className = "learning-slide-visual-img"; img.alt = "Rendered learning slide"; // Setting src via the DOM API ensures the worker-supplied data: URL // cannot escape the attribute boundary, even if a future change in // the bridge contract returns an unexpected payload shape. img.src = res.data_url; host.appendChild(img); } catch (err) { if (token !== state.learningSlideRenderToken) return; bridge.log(`renderLearningSlideVisual failed: ${err && err.message ? err.message : err}`, "warn"); this._showSlideVisualUnavailable(host); } }, _showSlideVisualUnavailable(host) { host.classList.remove("loading"); host.textContent = ""; const empty = document.createElement("div"); empty.className = "learning-empty-inline"; empty.textContent = "Slide visual unavailable."; host.appendChild(empty); const textWrap = document.getElementById("learningSlideTextWrap"); if (textWrap) textWrap.classList.add("show"); }, renderMicroQuiz(pathwayId, chapter) { const interval = Number(chapter?.micro_quiz_interval || 0); if (!interval || interval < 2) return ""; const slides = chapter.slides || []; // contentNumber is the 1-indexed slide number (slideIndex is now // 0-indexed since the intro state was removed). const contentNumber = state.selectedSlideIndex + 1; if (slides.length < interval + 2 || contentNumber >= slides.length) return ""; if (contentNumber % interval !== 0) return ""; const pathwayProgress = _ensureChild(state.learningProgress, pathwayId); const progress = _ensureChild(pathwayProgress, chapter.id); if (!progress.microTaken) progress.microTaken = {}; const key = String(contentNumber); return `
Micro Quiz Checkpoint
Long chapter check-in at slide ${contentNumber}. Recommended for retention.
`; }, renderQuizGate(pathwayId, chapter) { const slides = chapter.slides || []; if (!slides.length || !chapter.midpoint_quiz) return ""; const midpoint = Math.floor(slides.length / 2); const pathwayProgress = _ensureChild(state.learningProgress, pathwayId); const progress = _ensureChild(pathwayProgress, chapter.id); // slideIndex is now 0-indexed; the prior "intro skip" is no // longer needed. Trigger the quiz gate once the user reaches // the midpoint slide (was midpoint+1 in 1-indexed semantics). const shouldShow = state.selectedSlideIndex >= midpoint || progress.midpointTaken; if (!shouldShow) return ""; const quiz = chapter.midpoint_quiz; const qList = (quiz.questions || []); const questions = qList.map((q, idx) => `
${idx + 1}. ${utils.prettyText(q.prompt || "")}
${(q.choices || []).map((c, cidx) => ``).join("")}
${utils.prettyText(q.explanation || "")}
`).join(""); const placeholderQuestion = `
Quick checkpoint (placeholder): pick any answer to continue.
`; return `
Required Mid-Chapter Quiz (Take Required, Pass Not Required)
${questions || placeholderQuestion} `; }, renderChapterTest(chapter, pathwayId) { if (!chapter?.final_test) return ""; const slides = chapter.slides || []; // Render the test alongside the LAST slide. Previously slideIndex // had an extra "after-last-slide" position (slides.length) where // the test rendered; with the intro removed and slideIndex now // 0-indexed, the last slide IS the natural end-of-chapter spot. if (!slides.length || state.selectedSlideIndex < slides.length - 1) return ""; const pathwayProgress = _ensureChild(state.learningProgress, pathwayId); const progress = _ensureChild(pathwayProgress, chapter.id); const t = chapter.final_test; const qList = t.questions || []; const questions = qList.slice(0, 1).map((q, idx) => `
${idx + 1}. ${utils.prettyText(q.prompt || "Choose any answer to mark this optional test attempted.")}
${((q.choices || []).length ? q.choices : ["Option A", "Option B"]).map((c, cidx) => ``).join("")}
`).join(""); const placeholderQuestion = `
Optional chapter test placeholder: click any answer to mark attempted.
`; return `
Optional Recommended Chapter Test
${utils.prettyText(t.title || "Chapter Test")}
Questions: ${(t.questions || []).length}
${questions || placeholderQuestion} `; }, updateSlideControlState(pathwayId, chapter) { const slides = chapter.slides || []; const pathwayProgress = _ensureChild(state.learningProgress, pathwayId); const progress = _ensureChild(pathwayProgress, chapter.id); const midpoint = Math.floor(slides.length / 2); const prevDisabled = state.selectedSlideIndex <= 0; // slideIndex is now 0-indexed; midpoint quiz and end-of-chapter // bounds shift by 1 vs the old N+1 indexing. const blockedByQuiz = chapter.midpoint_quiz && !progress.midpointTaken && state.selectedSlideIndex >= midpoint; const nextDisabled = state.selectedSlideIndex >= slides.length - 1 || blockedByQuiz; // Update every prev/next stage-action button (top action bar + // duplicate bottom bar) so the new bottom advance controls // stay in sync with the top. document.querySelectorAll('[data-stage-action="prev"]').forEach(b => { b.disabled = prevDisabled; }); document.querySelectorAll('[data-stage-action="next"]').forEach(b => { b.disabled = nextDisabled; }); }, renderCapacityPage(res = null) { const preview = document.getElementById("capacityPreview"); const text = document.getElementById("capacityPageText"); const meta = document.getElementById("capacityPageMeta"); const stats = document.getElementById("capacityStats"); const report = document.getElementById("capacityReportOutput"); const prevBtn = document.getElementById("capacityPrevBtn"); const nextBtn = document.getElementById("capacityNextBtn"); if (!preview || !text || !meta || !stats || !prevBtn || !nextBtn || !report) return; if (res && res.data_url) { preview.classList.remove("loading"); preview.textContent = ""; const img = document.createElement("img"); img.className = "learning-slide-visual-img"; img.alt = "Capacity test slide"; img.draggable = false; // Setting src via the DOM API ensures the worker-supplied data: URL // cannot escape the attribute boundary. img.src = res.data_url; preview.appendChild(img); text.textContent = state.capacityState.pageText || ""; const p = state.capacityState.pageIndex + 1; const t = Math.max(1, state.capacityState.totalPages); meta.textContent = `Page ${p} / ${t}`; stats.textContent = `Chars on page: ${res.chars_on_page || 0} · Usable chars (non-space): ${res.usable_chars_on_page || 0} · Lines: ${res.max_lines || 0}`; state.capacityState.lastStats = { page: p, totalPages: t, charsOnPage: Number(res.chars_on_page || 0), usableCharsOnPage: Number(res.usable_chars_on_page || 0), maxLines: Number(res.max_lines || 0), lineHeightPx: Number(res.line_height_px || 0), withImage: !!res.with_image, overflowChars: Number(res.overflow_chars || 0), }; report.value = [ "Capacity Test Report", `with_image=${state.capacityState.lastStats.withImage}`, `page=${state.capacityState.lastStats.page}/${state.capacityState.lastStats.totalPages}`, `chars_on_page=${state.capacityState.lastStats.charsOnPage}`, `usable_chars_on_page=${state.capacityState.lastStats.usableCharsOnPage}`, `max_lines=${state.capacityState.lastStats.maxLines}`, `line_height_px=${state.capacityState.lastStats.lineHeightPx}`, `overflow_chars=${state.capacityState.lastStats.overflowChars}`, "", "PAGE_TEXT_START", state.capacityState.pageText || "", "PAGE_TEXT_END", ].join("\n"); } else if (!preview.innerHTML) { preview.classList.remove("loading"); preview.textContent = "Run analysis to render test slide."; text.textContent = ""; meta.textContent = ""; stats.textContent = ""; report.value = ""; } prevBtn.disabled = state.capacityState.pageIndex <= 0; nextBtn.disabled = state.capacityState.pageIndex >= Math.max(0, state.capacityState.totalPages - 1); }, showAnimStep(idx) { if (!state.currentSteps.length) return; idx = Math.max(0, Math.min(state.currentSteps.length - 1, idx)); if (state.transitionBusy) return; const step = state.currentSteps[idx]; const renderToken = ++state.stepRenderToken; const fromLatex = state.stepIdx >= 0 ? (state.currentSteps[state.stepIdx].after || state.currentSteps[state.stepIdx].before || "") : state.baseLatex; const toLatex = step.after || step.before || ""; state.currentAnimCopyText = utils.normalizeDisplayMath(toLatex || fromLatex || ""); state.transitionBusy = true; state.stepIdx = idx; this.updateIndicator(); document.querySelectorAll(".step-card").forEach((c, i) => c.classList.toggle("highlight", i === idx)); document.querySelectorAll(".anim-step-item").forEach((c, i) => c.classList.toggle("active", i === idx)); const display = document.getElementById("animMathDisplay"); const badge = document.getElementById("animRuleBadge"); const desc = document.getElementById("animDescription"); clearTimeout(state.badgeTimer); clearTimeout(state.descTimer); badge.className = "anim-rule-badge"; desc.className = "anim-description"; badge.textContent = ""; desc.textContent = ""; const onDone = () => { state.transitionBusy = false; if (state.queuedDirection !== 0) { state.queuedDirection = 0; // These will be called on app object window.appAPI.stepForward(); } }; if (step.rule === "final_result") { this.renderFinalSolution(display, toLatex, renderToken, onDone); badge.textContent = "SOLUTION"; badge.classList.add("solution"); } else { this.animateMathTransition(display, fromLatex, toLatex, renderToken, onDone); badge.textContent = (step.rule || "").replace(/_/g, " "); badge.classList.add("rule"); } desc.innerHTML = utils.prettyText(step.description || ""); state.badgeTimer = setTimeout(() => { if (renderToken !== state.stepRenderToken) return; badge.classList.add("visible"); }, 180); state.descTimer = setTimeout(() => { if (renderToken !== state.stepRenderToken) return; desc.classList.add("visible"); }, 320); this.drawAnimCanvas(step); }, animateMathTransition(display, beforeLatex, afterLatex, renderToken, onDone) { display.classList.remove("fade-in"); display.classList.add("fade-out"); setTimeout(() => { if (renderToken !== state.stepRenderToken) return; display.innerHTML = ""; const frame = document.createElement("div"); frame.className = "anim-transition-frame"; const compare = document.createElement("div"); compare.className = "anim-compare"; const prev = document.createElement("div"); prev.className = "anim-eq prev"; this.renderMath(prev, beforeLatex || afterLatex || ""); const arrow = document.createElement("div"); arrow.className = "anim-arrow"; arrow.textContent = "→"; const next = document.createElement("div"); next.className = "anim-eq next"; this.renderMath(next, afterLatex || beforeLatex || ""); compare.appendChild(prev); compare.appendChild(arrow); compare.appendChild(next); frame.appendChild(compare); display.appendChild(frame); compare.style.opacity = "0"; compare.style.transform = "translateY(8px)"; compare.style.transition = "opacity 420ms ease, transform 420ms ease"; this.renderMotionLayer(frame, beforeLatex, afterLatex, renderToken, state.GLYPH_ONLY_MS); requestAnimationFrame(() => { if (renderToken !== state.stepRenderToken) return; setTimeout(() => { if (renderToken !== state.stepRenderToken) return; compare.style.opacity = "1"; compare.style.transform = "translateY(0)"; next.classList.add("enter"); arrow.classList.add("enter"); }, state.GLYPH_ONLY_MS); }); display.classList.remove("fade-out"); display.classList.add("fade-in"); setTimeout(() => { if (renderToken !== state.stepRenderToken) return; if (typeof onDone === "function") onDone(); }, state.TRANSITION_MS); }, 220); }, renderFinalSolution(display, finalLatex, renderToken, onDone) { display.classList.remove("fade-in"); display.classList.add("fade-out"); setTimeout(() => { if (renderToken !== state.stepRenderToken) return; display.innerHTML = ""; const wrap = document.createElement("div"); wrap.className = "anim-final-solution"; const title = document.createElement("div"); title.className = "anim-final-title"; title.textContent = "Solution"; const eq = document.createElement("div"); eq.className = "anim-eq settled"; this.renderMath(eq, finalLatex || ""); state.currentAnimCopyText = utils.normalizeDisplayMath(finalLatex || ""); wrap.appendChild(title); wrap.appendChild(eq); display.appendChild(wrap); display.classList.remove("fade-out"); display.classList.add("fade-in"); setTimeout(() => { if (renderToken !== state.stepRenderToken) return; if (typeof onDone === "function") onDone(); }, 650); }, 180); }, renderMotionLayer(frame, beforeLatex, afterLatex, renderToken, durationMs) { const canvas = document.createElement("canvas"); canvas.className = "anim-motion-canvas"; frame.appendChild(canvas); const dpr = window.devicePixelRatio || 1; const w = Math.max(620, frame.clientWidth || 720); const h = Math.max(150, frame.clientHeight || 180); canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.scale(dpr, dpr); const leftTokens = utils.tokenizeGlyphTokens(beforeLatex); const rightTokens = utils.tokenizeGlyphTokens(afterLatex); if (!leftTokens.length && !rightTokens.length) { canvas.remove(); return; } const seed = utils.hashString(`${beforeLatex}=>${afterLatex}`); const rng = utils.mulberry32(seed || 1); const glyphs = this.buildGlyphParticles(leftTokens, rightTokens, rng, w, h, seed); const t0 = performance.now(); const duration = Math.max(300, durationMs || state.GLYPH_ONLY_MS); const paint = (now) => { if (renderToken !== state.stepRenderToken) { canvas.remove(); return; } const t = Math.min(1, (now - t0) / duration); ctx.clearRect(0, 0, w, h); ctx.textAlign = "center"; ctx.textBaseline = "middle"; glyphs.forEach((g, i) => { const p = this.particlePosition(g, t); const wobble = Math.sin((t * 10.5) + i * 0.61) * (1 - t) * 6; const x = p.x + wobble; const y = p.y + Math.cos((t * 8.2) + i * 0.41) * (1 - t) * 3; const alpha = t < 0.4 ? t * 1.8 : (1 - t) * 1.4; const a = Math.max(0, Math.min(0.82, alpha)); ctx.font = `700 ${g.size}px 'Segoe UI', system-ui, sans-serif`; ctx.strokeStyle = `rgba(12,16,25,${0.55 * a})`; ctx.lineWidth = 3; ctx.strokeText(g.text, x, y); ctx.fillStyle = g.color(a); ctx.fillText(g.text, x, y); }); if (t < 1) requestAnimationFrame(paint); else canvas.remove(); }; requestAnimationFrame(paint); }, renderStationaryStage(leftLatex, rightLatex) { const display = document.getElementById("animMathDisplay"); if (!display) return; display.innerHTML = ""; const frame = document.createElement("div"); frame.className = "anim-transition-frame"; const compare = document.createElement("div"); compare.className = "anim-compare"; const left = document.createElement("div"); left.className = "anim-eq prev"; this.renderMath(left, leftLatex || ""); compare.appendChild(left); if (rightLatex) { const arrow = document.createElement("div"); arrow.className = "anim-arrow enter"; arrow.textContent = "→"; const right = document.createElement("div"); right.className = "anim-eq next enter"; this.renderMath(right, rightLatex); compare.appendChild(arrow); compare.appendChild(right); } frame.appendChild(compare); display.appendChild(frame); state.currentAnimCopyText = utils.normalizeDisplayMath(rightLatex || leftLatex || ""); }, particlePosition(g, t) { const split = g.split || 0.55; if (t <= split) { const u = this.easeOutCubic(t / split); return { x: utils.lerp(g.start.x, g.mid.x, u), y: utils.lerp(g.start.y, g.mid.y, u), }; } const u = this.easeInOutCubic((t - split) / (1 - split)); return { x: utils.lerp(g.mid.x, g.end.x, u), y: utils.lerp(g.mid.y, g.end.y, u), }; }, easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }, easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }, buildGlyphParticles(leftTokens, rightTokens, rng, w, h, seed) { const particles = []; let leftOut = 0, rightOut = 0, leftIn = 0, rightIn = 0; const count = leftTokens.length + rightTokens.length; const cols = count > 14 ? 10 : 7; const spreadX = count > 20 ? 18 : 26; const spreadY = count > 20 ? 16 : 20; const halfCols = Math.floor(cols / 2); const mkSidePos = (side, slot, lane, inward) => { const row = Math.floor(slot / cols); const col = slot % cols; const baseX = side === "left" ? (inward ? w * 0.28 : w * 0.2) : (inward ? w * 0.72 : w * 0.8); const baseY = h * 0.52; return { x: baseX + (col - halfCols) * spreadX + (lane * 2), y: baseY + (row - 1) * spreadY, }; }; const mkMid = (i) => ({ x: w * 0.5 + (rng() - 0.5) * 110 + Math.sin((seed + i * 17) * 0.005) * 12, y: h * 0.48 + (rng() - 0.5) * 46, }); const baseSize = Math.max(11, 28 - Math.floor(count / 4)); const mkSize = () => Math.floor(baseSize + rng() * Math.min(6, baseSize - 11)); const leftColor = (a) => `rgba(245, 204, 102, ${a})`; const rightColor = (a) => `rgba(141, 204, 255, ${a})`; leftTokens.forEach((text, i) => { const toRight = ((i + seed) % 3) !== 0; const start = mkSidePos("left", leftOut++, 0, false); const end = toRight ? mkSidePos("right", rightIn++, 1, true) : mkSidePos("left", leftIn++, 1, true); particles.push({ text, start, mid: mkMid(i), end, split: 0.56, size: mkSize(), color: leftColor }); }); rightTokens.forEach((text, i) => { const toLeft = ((i + seed) % 3) !== 1; const start = mkSidePos("right", rightOut++, 0, false); const end = toLeft ? mkSidePos("left", leftIn++, 1, true) : mkSidePos("right", rightIn++, 1, true); particles.push({ text, start, mid: mkMid(i + 50), end, split: 0.54, size: mkSize(), color: rightColor }); }); return particles.slice(0, 56); }, renderMath(el, latex) { try { katex.render(latex, el, { throwOnError: false, displayMode: false, trust: false }); } catch (err) { bridge.log(`KaTeX render failed: ${err && err.message ? err.message : err}`, "warn"); el.textContent = latex; } }, drawAnimCanvas(step) { const needsVisual = step.type === "area" || step.type === "approach"; state.aCanvas.style.display = needsVisual ? "block" : "none"; if (!needsVisual) return; const dpr = window.devicePixelRatio || 1; const W = state.aCanvas.width / dpr, H = state.aCanvas.height / dpr; this.fillBg(state.aCtx, state.aCanvas); const ctx = state.aCtx; const hints = step.hints || {}; if (step.type === "area" && state.graphData && state.graphData.success) { this.drawMiniGraph(ctx, W, H, state.graphData, true, false); } else if (step.type === "approach" && state.graphData && state.graphData.success) { this.drawMiniGraph(ctx, W, H, state.graphData, false, true); } if (hints.formula) { ctx.fillStyle = "rgba(15,15,35,0.8)"; ctx.fillRect(W / 2 - 120, H - 50, 240, 36); ctx.fillStyle = "#fbbf24"; ctx.font = "14px monospace"; ctx.textAlign = "center"; ctx.fillText(hints.formula, W / 2, H - 28); } }, drawMiniGraph(ctx, W, H, data, showArea, showApproach) { const pad = 30, w = W - pad * 2, h = H - pad * 2; const xs = data.x, ys = data.y; if (!xs || !ys) return; const valid = xs.map((x, i) => [x, ys[i]]).filter(p => p[1] !== null); if (!valid.length) return; const xMin = valid[0][0], xMax = valid[valid.length - 1][0]; let yArr = valid.map(p => p[1]); yArr.sort((a, b) => a - b); const yLo = yArr[Math.floor(yArr.length * 0.02)] - 1; const yHi = yArr[Math.floor(yArr.length * 0.98)] + 1; const tx = x => pad + ((x - xMin) / (xMax - xMin || 1)) * w; const ty = y => pad + h - ((y - yLo) / (yHi - yLo || 1)) * h; if (showArea && state.solveResult && state.solveResult.detected_type === "INTEGRAL_DEFINITE") { const lo = parseFloat(document.getElementById("lowerBound").value) || 0; const hi = parseFloat(document.getElementById("upperBound").value) || 1; ctx.fillStyle = "rgba(233,69,96,0.25)"; ctx.beginPath(); ctx.moveTo(tx(lo), ty(0)); for (let i = 0; i < xs.length; i++) { if (xs[i] < lo || xs[i] > hi || ys[i] === null) continue; ctx.lineTo(tx(xs[i]), ty(ys[i])); } ctx.lineTo(tx(hi), ty(0)); ctx.closePath(); ctx.fill(); } ctx.strokeStyle = "#e94560"; ctx.lineWidth = 2; ctx.beginPath(); let started = false; for (let i = 0; i < xs.length; i++) { if (ys[i] === null) { started = false; continue; } const cx = tx(xs[i]), cy = ty(Math.max(yLo, Math.min(yHi, ys[i]))); if (!started) { ctx.moveTo(cx, cy); started = true; } else { ctx.lineTo(cx, cy); } } ctx.stroke(); if (showApproach) { const pt = parseFloat(document.getElementById("limitPoint").value); if (!isNaN(pt)) { const px = tx(pt); ctx.setLineDash([4, 4]); ctx.strokeStyle = "#fbbf24"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(px, pad); ctx.lineTo(px, pad + h); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = "#fbbf24"; ctx.font = "12px sans-serif"; ctx.textAlign = "center"; ctx.fillText("x → " + pt, px, pad - 5); } } }, updateIndicator() { const el = document.getElementById("stepIndicator"); if (el) el.textContent = `Step ${Math.max(0, state.stepIdx + 1)} / ${state.currentSteps.length}`; } };