abril4416
Eliminate Plotly autosize feedback loops with explicit wrapper-sized relayout
c04888d
Raw
History Blame Contribute Delete
26.6 kB
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 = `
<span class="landscape-pill">Start cost: ${first.cost.toFixed(2)}</span>
<span class="landscape-pill">Final cost: ${final.cost.toFixed(2)}</span>
<span class="landscape-pill">Reduction: ${reduction.toFixed(1)}%</span>
<span class="landscape-pill">Iterations: ${state.steps.length - 1}</span>
<span class="landscape-pill">Current step: ${state.currentStep}</span>
<span class="landscape-pill">Current cost: ${current.cost.toFixed(2)}</span>
`;
}
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]}<br>theta0: %{x:.4f}<br>theta1: %{y:.4f}<br>Cost J(theta): %{z:.4f}<extra></extra>";
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}<br>theta1: %{y:.3f}<br>Cost J(theta): %{z:.4f}<extra></extra>",
},
{
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 = `
<article class="matrix-card">
<p class="matrix-title">Cost Function</p>
<p class="formula-line">J(theta) = (1/2m) sum((theta0 + theta1*x1 - y)^2)</p>
</article>
<article class="matrix-card">
<p class="matrix-title">Result</p>
<p class="formula-line">y_hat = ${fmt(last.theta[0])} + ${fmt(last.theta[1])}*x1</p>
<p class="slice-label">parameters: 2 (theta0, theta1)</p>
<p class="slice-label">initial cost: ${first.cost.toFixed(6)}</p>
<p class="slice-label">final cost: ${last.cost.toFixed(6)}</p>
</article>
`;
}
function renderStepSummary() {
const s = state.steps[state.currentStep];
stepSummary.innerHTML = `
<article class="matrix-card">
<p class="matrix-title">Current Step ${s.step}</p>
<p class="formula-line">cost = ${s.cost.toFixed(6)}</p>
<p class="formula-line">theta = [${s.theta.map(fmt).join(", ")}]</p>
<p class="formula-line">gradient = [${s.grad.map(fmt).join(", ")}]</p>
</article>
`;
}
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();