const sampleCountInput = document.getElementById("sampleCount"); const learningRateInput = document.getElementById("learningRate"); const iterationCountInput = document.getElementById("iterationCount"); const noiseLevelInput = document.getElementById("noiseLevel"); const runGdBtn = document.getElementById("runGdBtn"); const gdStep = document.getElementById("gdStep"); const gdStepLabel = document.getElementById("gdStepLabel"); const playGdBtn = document.getElementById("playGdBtn"); const resetGdBtn = document.getElementById("resetGdBtn"); const fitCanvas = document.getElementById("fitCanvas"); const costCanvas = document.getElementById("costCanvas"); const landscapePlotWrap = document.getElementById("landscapePlotWrap"); const landscapePlot = document.getElementById("landscapePlot"); const landscapeSummary = document.getElementById("landscapeSummary"); const resetViewBtn = document.getElementById("resetViewBtn"); const plotSmallerBtn = document.getElementById("plotSmallerBtn"); const plotFitBtn = document.getElementById("plotFitBtn"); const plotLargerBtn = document.getElementById("plotLargerBtn"); const plotFullscreenBtn = document.getElementById("plotFullscreenBtn"); const toggleSurface = document.getElementById("toggleSurface"); const toggleContours = document.getElementById("toggleContours"); const toggleTrajectory = document.getElementById("toggleTrajectory"); const costSummary = document.getElementById("costSummary"); const stepSummary = document.getElementById("stepSummary"); const gdLogBody = document.getElementById("gdLogBody"); const fitCtx = fitCanvas.getContext("2d"); const costCtx = costCanvas.getContext("2d"); const DEFAULT_CAMERA = { eye: { x: 1.5, y: 1.5, z: 1.1 }, center: { x: 0, y: 0, z: 0 }, up: { x: 0, y: 0, z: 1 }, }; const RESIZE_EPSILON = 1; const DEBUG_RESIZE = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("debugResize"); const state = { data: [], steps: [], currentStep: 0, timer: null, landscapeCache: null, landscapeCamera: null, landscapePlotReady: false, landscapeResizeRaf: null, lastLandscapeSize: null, }; function debugResizeLog(source, extra = {}) { if (!DEBUG_RESIZE) return; const wrapRect = landscapePlotWrap.getBoundingClientRect(); const parent = landscapePlotWrap.parentElement; const parentRect = parent ? parent.getBoundingClientRect() : null; const docEl = document.documentElement; const payload = { source, wrapClient: `${landscapePlotWrap.clientWidth}x${landscapePlotWrap.clientHeight}`, wrapRect: `${wrapRect.width.toFixed(2)}x${wrapRect.height.toFixed(2)}`, parentRect: parentRect ? `${parentRect.width.toFixed(2)}x${parentRect.height.toFixed(2)}` : "n/a", win: `${window.innerWidth}x${window.innerHeight}`, viewport: `${docEl.clientWidth}x${docEl.clientHeight}`, scroll: `${docEl.scrollWidth}x${docEl.scrollHeight}`, bodyScrollY: window.scrollY, hasVScrollbar: docEl.scrollHeight > docEl.clientHeight, hasHScrollbar: docEl.scrollWidth > docEl.clientWidth, ...extra, }; console.debug("[linear-resize]", payload); } function init() { resizeCanvases(); fitLandscapeSize(); initLandscapeInteractions(); runGdBtn.addEventListener("click", runGradientDescent); window.addEventListener("resize", handleResize); gdStep.addEventListener("input", () => { stopPlayback(); state.currentStep = Number(gdStep.value); renderAll(); }); playGdBtn.addEventListener("click", () => { if (!state.steps.length) return; if (state.timer) stopPlayback(); else startPlayback(); }); resetGdBtn.addEventListener("click", () => { stopPlayback(); if (!state.steps.length) return; state.currentStep = 0; gdStep.value = "0"; renderAll(); }); runGradientDescent(); } function initLandscapeInteractions() { resetViewBtn.addEventListener("click", resetLandscapeView); plotFitBtn.addEventListener("click", () => fitLandscapeSize(true)); plotLargerBtn.addEventListener("click", () => scaleLandscapeSize(1.14)); plotSmallerBtn.addEventListener("click", () => scaleLandscapeSize(1 / 1.14)); plotFullscreenBtn.addEventListener("click", toggleLandscapeFullscreen); for (const cb of [toggleSurface, toggleContours, toggleTrajectory]) { cb.addEventListener("change", renderCostLandscape); } document.addEventListener("fullscreenchange", () => { plotFullscreenBtn.textContent = document.fullscreenElement === landscapePlotWrap ? "Exit Fullscreen" : "Fullscreen"; debugResizeLog("fullscreenchange", { inFullscreen: document.fullscreenElement === landscapePlotWrap }); if (document.fullscreenElement === landscapePlotWrap) { setLandscapeSize(window.innerWidth - 24, window.innerHeight - 24, true); return; } constrainLandscapeSizeToContainer(); }); } function handleResize() { debugResizeLog("window.resize"); resizeCanvases(); if (document.fullscreenElement === landscapePlotWrap) { setLandscapeSize(window.innerWidth - 24, window.innerHeight - 24, false); } else { constrainLandscapeSizeToContainer(); } if (state.steps.length) renderAll(); } function resizeCanvases() { resizeCanvasToContainer(fitCanvas, 0.46, 240, 480); resizeCanvasToContainer(costCanvas, 0.28, 200, 320); } function resizeCanvasToContainer(canvas, ratio, minHeight, maxHeight) { const parent = canvas.parentElement; if (!parent) return; const width = Math.max(320, Math.round(parent.getBoundingClientRect().width)); const targetHeight = Math.floor(width * ratio); const height = Math.max(minHeight, Math.min(maxHeight, targetHeight)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } } function getLandscapeFitSize() { const host = landscapePlotWrap.parentElement; const hostWidth = host ? host.getBoundingClientRect().width : window.innerWidth; const maxW = Math.max(320, Math.min(1000, hostWidth - 4)); if (window.innerWidth < 680) { return { width: maxW, height: 380 }; } if (window.innerWidth < 1024) { return { width: Math.min(maxW, 920), height: 560 }; } return { width: Math.min(maxW, 980), height: 640 }; } function fitLandscapeSize(forceResize = false) { const size = getLandscapeFitSize(); setLandscapeSize(size.width, size.height, forceResize); } function setLandscapeSize(rawW, rawH, forceResize = false) { const host = landscapePlotWrap.parentElement; const hostMaxW = host ? host.getBoundingClientRect().width - 4 : rawW; const isFullscreen = document.fullscreenElement === landscapePlotWrap; const minW = 320; const minH = 280; const maxW = isFullscreen ? Math.min(1800, window.innerWidth - 24) : Math.max(minW, hostMaxW); const maxH = isFullscreen ? Math.min(1200, window.innerHeight - 24) : Math.min(900, window.innerHeight - 150); const width = Math.round(Math.max(minW, Math.min(maxW, rawW))); const height = Math.round(Math.max(minH, Math.min(maxH, rawH))); const rect = landscapePlotWrap.getBoundingClientRect(); const widthChanged = Math.abs(Math.round(rect.width) - width) > RESIZE_EPSILON; const heightChanged = Math.abs(Math.round(rect.height) - height) > RESIZE_EPSILON; if (widthChanged || heightChanged) { landscapePlotWrap.style.width = `${width}px`; landscapePlotWrap.style.height = `${height}px`; debugResizeLog("setLandscapeSize.apply", { width, height, forceResize }); } if (forceResize || widthChanged || heightChanged) { queueLandscapeResize(true); } } function constrainLandscapeSizeToContainer() { const rect = landscapePlotWrap.getBoundingClientRect(); setLandscapeSize(rect.width, rect.height, false); } function scaleLandscapeSize(factor) { const rect = landscapePlotWrap.getBoundingClientRect(); setLandscapeSize(rect.width * factor, rect.height * factor, false); } function queueLandscapeResize(force = false) { if (!state.landscapePlotReady || typeof Plotly === "undefined") return; const width = Math.round(landscapePlotWrap.clientWidth); const height = Math.round(landscapePlotWrap.clientHeight); if (width <= 0 || height <= 0) return; if (!force && state.lastLandscapeSize) { const dw = Math.abs(width - state.lastLandscapeSize.width); const dh = Math.abs(height - state.lastLandscapeSize.height); if (dw <= RESIZE_EPSILON && dh <= RESIZE_EPSILON) return; } state.lastLandscapeSize = { width, height }; debugResizeLog("queueLandscapeResize", { width, height, force }); if (state.landscapeResizeRaf) { cancelAnimationFrame(state.landscapeResizeRaf); } state.landscapeResizeRaf = requestAnimationFrame(() => { state.landscapeResizeRaf = null; debugResizeLog("Plotly.relayout(size)", { width, height }); Plotly.relayout(landscapePlot, { width, height }); }); } function toggleLandscapeFullscreen() { if (document.fullscreenElement === landscapePlotWrap) { document.exitFullscreen(); return; } if (landscapePlotWrap.requestFullscreen) { landscapePlotWrap.requestFullscreen(); } } function resetLandscapeView() { state.landscapeCamera = cloneCamera(DEFAULT_CAMERA); if (!state.landscapePlotReady || typeof Plotly === "undefined") return; Plotly.relayout(landscapePlot, { "scene.camera": state.landscapeCamera }); } function runGradientDescent() { stopPlayback(); const config = readConfig(); state.data = generateData(config); state.steps = computeGradientDescent(state.data, config).steps; state.currentStep = 0; state.landscapeCache = null; gdStep.max = String(state.steps.length - 1); gdStep.value = "0"; renderAll(); } function readConfig() { const sampleCount = clampInt(Number(sampleCountInput.value), 10, 120, 50); const learningRate = clampNum(Number(learningRateInput.value), 0.001, 0.5, 0.02); const iterations = clampInt(Number(iterationCountInput.value), 5, 300, 120); const noise = clampNum(Number(noiseLevelInput.value), 0, 5, 0.35); sampleCountInput.value = String(sampleCount); learningRateInput.value = String(learningRate); iterationCountInput.value = String(iterations); noiseLevelInput.value = String(noise); return { sampleCount, learningRate, iterations, noise }; } function generateData(config) { const rows = []; for (let i = 0; i < config.sampleCount; i += 1) { const x1 = rand(-5, 5); const y = 8 + 4.2 * x1 + rand(-config.noise, config.noise); rows.push({ x: [x1], y }); } return rows; } function computeGradientDescent(data, config) { let theta = [-12, 12]; const steps = []; for (let step = 0; step <= config.iterations; step += 1) { const { cost, grad } = costAndGradient(data, theta); steps.push({ step, theta: theta.slice(), grad: grad.slice(), cost }); if (step === config.iterations) break; theta = theta.map((t, j) => t - config.learningRate * grad[j]); } return { steps }; } function costAndGradient(data, theta) { const m = data.length; let sumSq = 0; const grad = [0, 0]; for (const row of data) { const yHat = theta[0] + theta[1] * row.x[0]; const err = yHat - row.y; sumSq += err * err; grad[0] += err; grad[1] += err * row.x[0]; } grad[0] /= m; grad[1] /= m; return { cost: sumSq / (2 * m), grad }; } function renderAll() { if (!state.steps.length) return; gdStepLabel.textContent = `${state.currentStep} / ${state.steps.length - 1}`; renderFitPlot(); renderCostPlot(); renderCostLandscape(); renderCostSummary(); renderStepSummary(); renderLogTable(); } function renderFitPlot() { const ctx = fitCtx; const { width, height } = fitCanvas; const pad = 52; const s = state.steps[state.currentStep]; ctx.clearRect(0, 0, width, height); const xs = state.data.map((d) => d.x[0]); const ys = state.data.map((d) => d.y); const xMin = Math.min(...xs) - 1; const xMax = Math.max(...xs) + 1; const yMin = Math.min(...ys) - 2; const yMax = Math.max(...ys) + 2; const toX = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad); const toY = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad); drawAxes2D(ctx, width, height, pad, "x", "y"); ctx.fillStyle = "#0c7b73"; for (const row of state.data) { ctx.beginPath(); ctx.arc(toX(row.x[0]), toY(row.y), 4, 0, Math.PI * 2); ctx.fill(); } const yL = s.theta[0] + s.theta[1] * xMin; const yR = s.theta[0] + s.theta[1] * xMax; ctx.strokeStyle = "#dd5e2f"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(toX(xMin), toY(yL)); ctx.lineTo(toX(xMax), toY(yR)); ctx.stroke(); } function renderCostPlot() { const ctx = costCtx; const { width, height } = costCanvas; const pad = 40; const costs = state.steps.map((s) => s.cost); ctx.clearRect(0, 0, width, height); const minC = Math.min(...costs); const maxC = Math.max(...costs); const toX = (i) => pad + (i / (costs.length - 1 || 1)) * (width - 2 * pad); const toY = (c) => height - pad - ((c - minC) / (maxC - minC || 1)) * (height - 2 * pad); drawAxes2D(ctx, width, height, pad, "iteration", "cost"); ctx.strokeStyle = "#0c7b73"; ctx.lineWidth = 2.2; ctx.beginPath(); costs.forEach((cost, i) => { const x = toX(i); const y = toY(cost); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); ctx.fillStyle = "#dd5e2f"; ctx.beginPath(); ctx.arc(toX(state.currentStep), toY(costs[state.currentStep]), 5, 0, Math.PI * 2); ctx.fill(); } function renderCostLandscape() { const data = getLandscapeData(); renderLandscapeSummary(); renderLandscapePlotly(data); } function getLandscapeData() { if (state.landscapeCache) return state.landscapeCache; const pathTheta0 = state.steps.map((s) => s.theta[0]); const pathTheta1 = state.steps.map((s) => s.theta[1]); const t0MinRaw = Math.min(...pathTheta0); const t0MaxRaw = Math.max(...pathTheta0); const t1MinRaw = Math.min(...pathTheta1); const t1MaxRaw = Math.max(...pathTheta1); const t0Pad = (t0MaxRaw - t0MinRaw || 1) * 0.35; const t1Pad = (t1MaxRaw - t1MinRaw || 1) * 0.35; const t0Min = t0MinRaw - t0Pad; const t0Max = t0MaxRaw + t0Pad; const t1Min = t1MinRaw - t1Pad; const t1Max = t1MaxRaw + t1Pad; const gridN = 64; const theta0Values = []; const theta1Values = []; for (let i = 0; i < gridN; i += 1) { theta0Values.push(t0Min + (i / (gridN - 1)) * (t0Max - t0Min)); theta1Values.push(t1Min + (i / (gridN - 1)) * (t1Max - t1Min)); } const grid = []; let zMin = Infinity; let zMax = -Infinity; for (let gy = 0; gy < gridN; gy += 1) { const t1 = theta1Values[gy]; const row = []; for (let gx = 0; gx < gridN; gx += 1) { const t0 = theta0Values[gx]; const cost = costAndGradient(state.data, [t0, t1]).cost; row.push(cost); zMin = Math.min(zMin, cost); zMax = Math.max(zMax, cost); } grid.push(row); } const path = state.steps.map((s) => ({ step: s.step, t0: s.theta[0], t1: s.theta[1], z: s.cost, g0: s.grad[0], g1: s.grad[1], })); const contourLines = buildContourLines(theta0Values, theta1Values, grid, zMin, zMax, 9); state.landscapeCache = { grid, gridN, theta0Values, theta1Values, t0Min, t0Max, t1Min, t1Max, zMin, zMax, path, contourLines, }; return state.landscapeCache; } function buildContourLines(theta0Values, theta1Values, grid, zMin, zMax, levelsCount) { const xs = []; const ys = []; const zs = []; const zBase = zMin; for (let levelIdx = 1; levelIdx <= levelsCount; levelIdx += 1) { const level = zMin + (levelIdx / (levelsCount + 1)) * (zMax - zMin); for (let y = 0; y < theta1Values.length - 1; y += 1) { for (let x = 0; x < theta0Values.length - 1; x += 1) { const p00 = { x: theta0Values[x], y: theta1Values[y], z: grid[y][x] }; const p10 = { x: theta0Values[x + 1], y: theta1Values[y], z: grid[y][x + 1] }; const p11 = { x: theta0Values[x + 1], y: theta1Values[y + 1], z: grid[y + 1][x + 1] }; const p01 = { x: theta0Values[x], y: theta1Values[y + 1], z: grid[y + 1][x] }; const intersections = []; addContourIntersection(intersections, p00, p10, level); addContourIntersection(intersections, p10, p11, level); addContourIntersection(intersections, p11, p01, level); addContourIntersection(intersections, p01, p00, level); if (intersections.length === 2) { xs.push(intersections[0].x, intersections[1].x, null); ys.push(intersections[0].y, intersections[1].y, null); zs.push(zBase, zBase, null); } else if (intersections.length === 4) { xs.push(intersections[0].x, intersections[1].x, null, intersections[2].x, intersections[3].x, null); ys.push(intersections[0].y, intersections[1].y, null, intersections[2].y, intersections[3].y, null); zs.push(zBase, zBase, null, zBase, zBase, null); } } } } return { x: xs, y: ys, z: zs }; } function addContourIntersection(bucket, a, b, level) { const aSide = a.z - level; const bSide = b.z - level; if (aSide === 0 && bSide === 0) return; if (aSide * bSide > 0) return; const denom = b.z - a.z; const t = Math.abs(denom) < 1e-12 ? 0.5 : (level - a.z) / denom; if (t < 0 || t > 1) return; bucket.push({ x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y), }); } function renderLandscapeSummary() { if (!state.steps.length) { landscapeSummary.innerHTML = ""; return; } const first = state.steps[0]; const final = state.steps[state.steps.length - 1]; const current = state.steps[state.currentStep]; const reduction = ((first.cost - final.cost) / Math.max(1e-9, first.cost)) * 100; landscapeSummary.innerHTML = ` Start cost: ${first.cost.toFixed(2)} Final cost: ${final.cost.toFixed(2)} Reduction: ${reduction.toFixed(1)}% Iterations: ${state.steps.length - 1} Current step: ${state.currentStep} Current cost: ${current.cost.toFixed(2)} `; } function renderLandscapePlotly(data) { if (typeof Plotly === "undefined") return; const trajectoryPath = data.path; const current = data.path[state.currentStep]; const start = data.path[0]; const final = data.path[data.path.length - 1]; const baseHover = "Iteration: %{customdata[0]}
theta0: %{x:.4f}
theta1: %{y:.4f}
Cost J(theta): %{z:.4f}"; const traces = [ { type: "surface", name: "Cost surface", x: data.theta0Values, y: data.theta1Values, z: data.grid, opacity: 0.82, colorscale: "Viridis", showscale: true, colorbar: { title: "Cost J", thickness: 16, }, contours: { z: { show: false, }, }, visible: toggleSurface.checked, hovertemplate: "theta0: %{x:.3f}
theta1: %{y:.3f}
Cost J(theta): %{z:.4f}", }, { type: "scatter3d", mode: "lines", name: "Contours", x: data.contourLines.x, y: data.contourLines.y, z: data.contourLines.z, line: { color: "rgba(25,45,58,0.65)", width: 3 }, visible: toggleContours.checked, hoverinfo: "skip", }, { type: "scatter3d", mode: "lines+markers", name: "Gradient descent trajectory", x: trajectoryPath.map((p) => p.t0), y: trajectoryPath.map((p) => p.t1), z: trajectoryPath.map((p) => p.z), customdata: trajectoryPath.map((p) => [p.step]), line: { color: "#f97316", width: 7 }, marker: { size: 3.8, color: "#b42318", opacity: 0.95 }, visible: toggleTrajectory.checked, hovertemplate: baseHover, }, { type: "scatter3d", mode: "markers+text", name: "Start", x: [start.t0], y: [start.t1], z: [start.z], customdata: [[start.step]], marker: { size: 10, color: "#0c7b73", line: { color: "#0b2230", width: 1.2 } }, text: ["Start"], textposition: "top center", visible: toggleTrajectory.checked, hovertemplate: baseHover, }, { type: "scatter3d", mode: "markers+text", name: "Final", x: [final.t0], y: [final.t1], z: [final.z], customdata: [[final.step]], marker: { size: 10, color: "#dd5e2f", line: { color: "#0b2230", width: 1.2 } }, text: ["Final"], textposition: "top center", visible: toggleTrajectory.checked, hovertemplate: baseHover, }, { type: "scatter3d", mode: "markers+text", name: "Current", x: [current.t0], y: [current.t1], z: [current.z], customdata: [[current.step]], marker: { size: 9, color: "#111827", line: { color: "#f59e0b", width: 2 } }, text: [state.currentStep === data.path.length - 1 ? "Final" : `Step ${state.currentStep}`], textposition: "bottom center", visible: toggleTrajectory.checked, hovertemplate: baseHover, }, ]; const sceneCamera = state.landscapeCamera || cloneCamera(DEFAULT_CAMERA); const plotWidth = Math.round(landscapePlotWrap.clientWidth); const plotHeight = Math.round(landscapePlotWrap.clientHeight); const layout = { autosize: false, width: plotWidth, height: plotHeight, margin: { l: 0, r: 0, t: 8, b: 0 }, paper_bgcolor: "#ffffff", scene: { xaxis: { title: "theta0", range: [data.t0Min, data.t0Max], showspikes: false, backgroundcolor: "rgba(247, 248, 252, 0.85)", }, yaxis: { title: "theta1", range: [data.t1Min, data.t1Max], showspikes: false, backgroundcolor: "rgba(247, 248, 252, 0.85)", }, zaxis: { title: "Cost J(theta)", range: [data.zMin, data.zMax], showspikes: false, backgroundcolor: "rgba(249, 250, 252, 0.9)", }, aspectmode: "manual", aspectratio: { x: 1.12, y: 1.12, z: 0.78 }, camera: sceneCamera, dragmode: "turntable", }, showlegend: false, uirevision: "landscape-ui", }; const config = { responsive: false, displaylogo: false, scrollZoom: true, doubleClick: "reset", modeBarButtonsToRemove: ["lasso2d", "select2d"], }; const plotPromise = state.landscapePlotReady ? Plotly.react(landscapePlot, traces, layout, config) : Plotly.newPlot(landscapePlot, traces, layout, config); plotPromise.then(() => { if (!state.landscapePlotReady) { state.landscapePlotReady = true; state.lastLandscapeSize = null; landscapePlot.on("plotly_relayout", (ev) => { if (ev && ev["scene.camera"]) { state.landscapeCamera = cloneCamera(ev["scene.camera"]); } debugResizeLog("plotly_relayout", { hasCamera: !!(ev && ev["scene.camera"]), }); }); queueLandscapeResize(true); } }); } function cloneCamera(camera) { return { eye: { ...camera.eye }, center: { ...camera.center }, up: { ...camera.up }, }; } function renderCostSummary() { const first = state.steps[0]; const last = state.steps[state.steps.length - 1]; costSummary.innerHTML = `

Cost Function

J(theta) = (1/2m) sum((theta0 + theta1*x1 - y)^2)

Result

y_hat = ${fmt(last.theta[0])} + ${fmt(last.theta[1])}*x1

parameters: 2 (theta0, theta1)

initial cost: ${first.cost.toFixed(6)}

final cost: ${last.cost.toFixed(6)}

`; } function renderStepSummary() { const s = state.steps[state.currentStep]; stepSummary.innerHTML = `

Current Step ${s.step}

cost = ${s.cost.toFixed(6)}

theta = [${s.theta.map(fmt).join(", ")}]

gradient = [${s.grad.map(fmt).join(", ")}]

`; } function renderLogTable() { gdLogBody.innerHTML = ""; for (const s of state.steps) { const tr = document.createElement("tr"); if (s.step === state.currentStep) tr.classList.add("active-log-row"); const cells = [ String(s.step), s.cost.toFixed(6), `[${s.theta.map(fmt).join(", ")}]`, `[${s.grad.map(fmt).join(", ")}]`, ]; cells.forEach((txt) => { const td = document.createElement("td"); td.textContent = txt; tr.appendChild(td); }); gdLogBody.appendChild(tr); } } function drawAxes2D(ctx, width, height, pad, xLabel, yLabel) { ctx.strokeStyle = "#8f9ba1"; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(pad, height - pad); ctx.lineTo(width - pad, height - pad); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pad, height - pad); ctx.lineTo(pad, pad); ctx.stroke(); ctx.fillStyle = "#4c5961"; ctx.font = "13px 'Courier New', monospace"; ctx.fillText(xLabel, width - pad - 50, height - pad + 22); ctx.fillText(yLabel, pad - 30, pad - 10); } function startPlayback() { if (state.timer || !state.steps.length) return; playGdBtn.textContent = "Pause"; state.timer = setInterval(() => { if (!document.body.contains(gdStep)) { stopPlayback(); return; } if (state.currentStep >= state.steps.length - 1) { stopPlayback(); return; } state.currentStep += 1; gdStep.value = String(state.currentStep); renderAll(); }, 220); } function stopPlayback() { if (!state.timer) { playGdBtn.textContent = "Play"; return; } clearInterval(state.timer); state.timer = null; playGdBtn.textContent = "Play"; } function fmt(n) { return Number(n).toFixed(4); } function rand(min, max) { return min + Math.random() * (max - min); } function clampInt(v, min, max, fallback) { if (!Number.isFinite(v)) return fallback; return Math.max(min, Math.min(max, Math.round(v))); } function clampNum(v, min, max, fallback) { if (!Number.isFinite(v)) return fallback; return Math.max(min, Math.min(max, v)); } init();