Spaces:
Running
Running
| const plotWrap = document.getElementById("landscapePlotWrap"); | |
| const plotEl = document.getElementById("landscapePlot"); | |
| const classifierCanvas = document.getElementById("classifierCanvas"); | |
| const classifierCtx = classifierCanvas.getContext("2d"); | |
| const controls = { | |
| caseSelect: document.getElementById("caseSelect"), | |
| lr: document.getElementById("lr"), | |
| lrInput: document.getElementById("lrInput"), | |
| iters: document.getElementById("iters"), | |
| p1: document.getElementById("startP1"), | |
| p2: document.getElementById("startP2"), | |
| speed: document.getElementById("speed"), | |
| runBtn: document.getElementById("runBtn"), | |
| stepBtn: document.getElementById("stepBtn"), | |
| autoBtn: document.getElementById("autoBtn"), | |
| restartBtn: document.getElementById("restartBtn"), | |
| regenBtn: document.getElementById("regenBtn"), | |
| clearBtn: document.getElementById("clearBtn"), | |
| landscapeView: document.getElementById("landscapeView"), | |
| resetViewBtn: document.getElementById("resetViewBtn"), | |
| plotSmallerBtn: document.getElementById("plotSmallerBtn"), | |
| plotFitBtn: document.getElementById("plotFitBtn"), | |
| plotLargerBtn: document.getElementById("plotLargerBtn"), | |
| plotFullscreenBtn: document.getElementById("plotFullscreenBtn"), | |
| toggleSurface: document.getElementById("toggleSurface"), | |
| toggleContours: document.getElementById("toggleContours"), | |
| toggleTrajectory: document.getElementById("toggleTrajectory"), | |
| toggleCurrent: document.getElementById("toggleCurrent"), | |
| stepSlider: document.getElementById("landscapeStep"), | |
| }; | |
| const labels = { | |
| formula: document.getElementById("formulaText"), | |
| lr: document.getElementById("lrValue"), | |
| iters: document.getElementById("iterValue"), | |
| p1: document.getElementById("p1Value"), | |
| p2: document.getElementById("p2Value"), | |
| speed: document.getElementById("speedValue"), | |
| stepLabel: document.getElementById("landscapeStepLabel"), | |
| }; | |
| const modelDefinition = document.getElementById("modelDefinition"); | |
| const landscapeSummary = document.getElementById("landscapeSummary"); | |
| const stats = document.getElementById("costStats"); | |
| const clipInfo = document.getElementById("clipInfo"); | |
| const minimumInfo = document.getElementById("minimumInfo"); | |
| const stabilityWarning = document.getElementById("stabilityWarning"); | |
| 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 GRID_N = 60; | |
| const MIN_SPAN = 2.0; | |
| const caseMeta = { | |
| case1: { | |
| p1Name: "b0", | |
| p2Name: "b1", | |
| p1Label: "b0 (intercept)", | |
| p2Label: "b1 (weight for x)", | |
| model: "z = b0 + b1*x", | |
| explain: [ | |
| "Cost landscape: J(b0, b1)", | |
| "b0 = intercept, b1 = coefficient of x", | |
| ], | |
| formula: "Case 1 logistic: p(y=1|x) = 1 / (1 + exp(-(b0 + b1x)))", | |
| }, | |
| case2: { | |
| p1Name: "w1", | |
| p2Name: "w2", | |
| p1Label: "w1 (weight for x1)", | |
| p2Label: "w2 (weight for x2)", | |
| model: "z = w1*x1 + w2*x2", | |
| explain: [ | |
| "Cost landscape: J(w1, w2)", | |
| "w1 = coefficient of x1, w2 = coefficient of x2", | |
| "No intercept is used in this model.", | |
| ], | |
| formula: "Case 2 logistic: p(y=1|x1,x2) = 1 / (1 + exp(-(w1x1 + w2x2)))", | |
| }, | |
| }; | |
| const state = { | |
| datasets: { case1: [], case2: [] }, | |
| dataVersion: 0, | |
| trajectory: [], | |
| stepIndex: 0, | |
| timer: null, | |
| unstableWarning: "", | |
| gridCache: { case1: null, case2: null }, | |
| plotReady: false, | |
| camera3d: null, | |
| resizeRaf: null, | |
| lastPlotSize: null, | |
| }; | |
| function debugResizeLog(source, extra = {}) { | |
| if (!DEBUG_RESIZE) return; | |
| const wrapRect = plotWrap.getBoundingClientRect(); | |
| const parent = plotWrap.parentElement; | |
| const parentRect = parent ? parent.getBoundingClientRect() : null; | |
| const docEl = document.documentElement; | |
| const payload = { | |
| source, | |
| wrapClient: `${plotWrap.clientWidth}x${plotWrap.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("[logistic-resize]", payload); | |
| } | |
| function hasPlotly() { | |
| return typeof window !== "undefined" && typeof window.Plotly !== "undefined"; | |
| } | |
| function showPlotFallback(message) { | |
| plotEl.innerHTML = `<div class="plot-fallback">${message}</div>`; | |
| } | |
| function sigmoid(z) { | |
| if (z >= 0) { | |
| const e = Math.exp(-z); | |
| return 1 / (1 + e); | |
| } | |
| const e = Math.exp(z); | |
| return e / (1 + e); | |
| } | |
| function softplus(z) { | |
| if (z > 35) return z; | |
| if (z < -35) return Math.exp(z); | |
| return Math.log1p(Math.exp(z)); | |
| } | |
| function randNormal(mean = 0, std = 1) { | |
| const u1 = Math.max(1e-12, Math.random()); | |
| const u2 = Math.random(); | |
| const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); | |
| return mean + z0 * std; | |
| } | |
| function percentile(sortedValues, q) { | |
| if (sortedValues.length === 0) return NaN; | |
| const pos = (sortedValues.length - 1) * q; | |
| const lo = Math.floor(pos); | |
| const hi = Math.min(sortedValues.length - 1, lo + 1); | |
| const t = pos - lo; | |
| return sortedValues[lo] * (1 - t) + sortedValues[hi] * t; | |
| } | |
| function cloneCamera(camera) { | |
| return { | |
| eye: { ...camera.eye }, | |
| center: { ...camera.center }, | |
| up: { ...camera.up }, | |
| }; | |
| } | |
| function formatCost(v) { | |
| if (!Number.isFinite(v)) return "--"; | |
| if (Math.abs(v) < 1) return v.toFixed(4); | |
| if (Math.abs(v) < 100) return v.toFixed(3); | |
| return v.toFixed(2); | |
| } | |
| function generateSyntheticData() { | |
| const case1 = []; | |
| const case2 = []; | |
| const trueB0 = -0.15; | |
| const trueB1 = 1.05; | |
| for (let i = 0; i < 160; i += 1) { | |
| const center = i < 80 ? -1.5 : 1.5; | |
| const x = randNormal(center, 1.9); | |
| const p = sigmoid(trueB0 + trueB1 * x); | |
| const y = Math.random() < p ? 1 : 0; | |
| case1.push({ x, y }); | |
| } | |
| const trueW1 = 1.1; | |
| const trueW2 = 0.95; | |
| for (let i = 0; i < 200; i += 1) { | |
| const center1 = i < 100 ? -1.4 : 1.4; | |
| const center2 = i < 100 ? -1.2 : 1.2; | |
| const x1 = randNormal(center1, 1.7); | |
| const x2 = randNormal(center2 + 0.25 * (x1 - center1), 1.55); | |
| const p = sigmoid(trueW1 * x1 + trueW2 * x2); | |
| const y = Math.random() < p ? 1 : 0; | |
| case2.push({ x1, x2, y }); | |
| } | |
| state.datasets = { case1, case2 }; | |
| state.dataVersion += 1; | |
| state.gridCache = { case1: null, case2: null }; | |
| } | |
| function costFor(caseKey, params) { | |
| const data = state.datasets[caseKey]; | |
| let total = 0; | |
| if (caseKey === "case1") { | |
| const [b0, b1] = params; | |
| for (const row of data) { | |
| const z = b0 + b1 * row.x; | |
| total += softplus(z) - row.y * z; | |
| } | |
| } else { | |
| const [w1, w2] = params; | |
| for (const row of data) { | |
| const z = w1 * row.x1 + w2 * row.x2; | |
| total += softplus(z) - row.y * z; | |
| } | |
| } | |
| return total / data.length; | |
| } | |
| function gradFor(caseKey, params) { | |
| const data = state.datasets[caseKey]; | |
| let g1 = 0; | |
| let g2 = 0; | |
| if (caseKey === "case1") { | |
| const [b0, b1] = params; | |
| for (const row of data) { | |
| const p = sigmoid(b0 + b1 * row.x); | |
| const diff = p - row.y; | |
| g1 += diff; | |
| g2 += diff * row.x; | |
| } | |
| } else { | |
| const [w1, w2] = params; | |
| for (const row of data) { | |
| const p = sigmoid(w1 * row.x1 + w2 * row.x2); | |
| const diff = p - row.y; | |
| g1 += diff * row.x1; | |
| g2 += diff * row.x2; | |
| } | |
| } | |
| const m = data.length; | |
| return [g1 / m, g2 / m]; | |
| } | |
| function estimateAnchorPoint(caseKey) { | |
| let params = [0, 0]; | |
| const lr = 0.1; | |
| for (let i = 0; i < 220; i += 1) { | |
| const grad = gradFor(caseKey, params); | |
| const next = [params[0] - lr * grad[0], params[1] - lr * grad[1]]; | |
| if (!Number.isFinite(next[0]) || !Number.isFinite(next[1])) break; | |
| params = next; | |
| } | |
| return params; | |
| } | |
| function buildTrajectory(caseKey, start, lr, iters) { | |
| const path = []; | |
| let params = [start[0], start[1]]; | |
| let unstable = false; | |
| path.push({ step: 0, p1: params[0], p2: params[1], cost: costFor(caseKey, params), dCost: 0 }); | |
| for (let i = 0; i < iters; i += 1) { | |
| const grad = gradFor(caseKey, params); | |
| const next = [params[0] - lr * grad[0], params[1] - lr * grad[1]]; | |
| if (!Number.isFinite(next[0]) || !Number.isFinite(next[1])) { | |
| unstable = true; | |
| break; | |
| } | |
| const nextCost = costFor(caseKey, next); | |
| if (!Number.isFinite(nextCost) || nextCost > 1e6) { | |
| unstable = true; | |
| break; | |
| } | |
| const prevCost = path[path.length - 1].cost; | |
| params = next; | |
| path.push({ step: i + 1, p1: params[0], p2: params[1], cost: nextCost, dCost: nextCost - prevCost }); | |
| } | |
| return { path, unstable }; | |
| } | |
| function getTrajectoryBounds(caseKey) { | |
| const anchor = estimateAnchorPoint(caseKey); | |
| const p1s = [anchor[0], Number(controls.p1.value)]; | |
| const p2s = [anchor[1], Number(controls.p2.value)]; | |
| for (const s of state.trajectory) { | |
| p1s.push(s.p1); | |
| p2s.push(s.p2); | |
| } | |
| let p1Min = Math.min(...p1s); | |
| let p1Max = Math.max(...p1s); | |
| let p2Min = Math.min(...p2s); | |
| let p2Max = Math.max(...p2s); | |
| let span1 = p1Max - p1Min; | |
| let span2 = p2Max - p2Min; | |
| if (span1 < MIN_SPAN) { | |
| const add = (MIN_SPAN - span1) / 2; | |
| p1Min -= add; | |
| p1Max += add; | |
| span1 = MIN_SPAN; | |
| } | |
| if (span2 < MIN_SPAN) { | |
| const add = (MIN_SPAN - span2) / 2; | |
| p2Min -= add; | |
| p2Max += add; | |
| span2 = MIN_SPAN; | |
| } | |
| const pad1 = Math.max(0.4 * span1, 0.9); | |
| const pad2 = Math.max(0.4 * span2, 0.9); | |
| return { | |
| p1Min: p1Min - pad1, | |
| p1Max: p1Max + pad1, | |
| p2Min: p2Min - pad2, | |
| p2Max: p2Max + pad2, | |
| }; | |
| } | |
| function getGridCacheKey(caseKey) { | |
| const first = state.trajectory[0]; | |
| const last = state.trajectory[state.trajectory.length - 1]; | |
| return [ | |
| state.dataVersion, | |
| caseKey, | |
| controls.p1.value, | |
| controls.p2.value, | |
| state.trajectory.length, | |
| first ? `${first.p1.toFixed(4)}:${first.p2.toFixed(4)}` : "none", | |
| last ? `${last.p1.toFixed(4)}:${last.p2.toFixed(4)}` : "none", | |
| ].join("|"); | |
| } | |
| function buildGrid(caseKey) { | |
| const bounds = getTrajectoryBounds(caseKey); | |
| const p1Values = []; | |
| const p2Values = []; | |
| for (let i = 0; i < GRID_N; i += 1) { | |
| p1Values.push(bounds.p1Min + (i / (GRID_N - 1)) * (bounds.p1Max - bounds.p1Min)); | |
| p2Values.push(bounds.p2Min + (i / (GRID_N - 1)) * (bounds.p2Max - bounds.p2Min)); | |
| } | |
| const vals = []; | |
| const flat = []; | |
| let min = Infinity; | |
| let max = -Infinity; | |
| let minI = 0; | |
| let minJ = 0; | |
| for (let j = 0; j < GRID_N; j += 1) { | |
| const row = []; | |
| const p2 = p2Values[j]; | |
| for (let i = 0; i < GRID_N; i += 1) { | |
| const p1 = p1Values[i]; | |
| const c = costFor(caseKey, [p1, p2]); | |
| row.push(c); | |
| flat.push(c); | |
| if (c < min) { | |
| min = c; | |
| minI = i; | |
| minJ = j; | |
| } | |
| if (c > max) max = c; | |
| } | |
| vals.push(row); | |
| } | |
| const sorted = flat.slice().sort((a, b) => a - b); | |
| let clipMax = percentile(sorted, 0.975); | |
| if (!Number.isFinite(clipMax)) clipMax = max; | |
| clipMax = Math.min(max, Math.max(min + 1e-6, clipMax)); | |
| return { | |
| key: "", | |
| p1Values, | |
| p2Values, | |
| vals, | |
| min, | |
| max, | |
| clipMax, | |
| p1Min: bounds.p1Min, | |
| p1Max: bounds.p1Max, | |
| p2Min: bounds.p2Min, | |
| p2Max: bounds.p2Max, | |
| minPoint: { | |
| p1: p1Values[minI], | |
| p2: p2Values[minJ], | |
| cost: min, | |
| }, | |
| }; | |
| } | |
| function getGrid(caseKey) { | |
| const key = getGridCacheKey(caseKey); | |
| const cached = state.gridCache[caseKey]; | |
| if (cached && cached.key === key) return cached; | |
| const built = buildGrid(caseKey); | |
| built.key = key; | |
| state.gridCache[caseKey] = built; | |
| return built; | |
| } | |
| function collectEdgeHit(bucket, a, b, level) { | |
| const da = a.z - level; | |
| const db = b.z - level; | |
| if (da === 0 && db === 0) return; | |
| if (da * db > 0) return; | |
| const den = b.z - a.z; | |
| const t = Math.abs(den) < 1e-12 ? 0.5 : (level - a.z) / den; | |
| 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 buildContourSegments3D(grid) { | |
| const xs = []; | |
| const ys = []; | |
| const zs = []; | |
| const levels = 9; | |
| const zFloor = grid.min; | |
| for (let levelIdx = 1; levelIdx <= levels; levelIdx += 1) { | |
| const level = grid.min + (levelIdx / (levels + 1)) * (grid.clipMax - grid.min); | |
| for (let j = 0; j < GRID_N - 1; j += 1) { | |
| for (let i = 0; i < GRID_N - 1; i += 1) { | |
| const p00 = { x: grid.p1Values[i], y: grid.p2Values[j], z: grid.vals[j][i] }; | |
| const p10 = { x: grid.p1Values[i + 1], y: grid.p2Values[j], z: grid.vals[j][i + 1] }; | |
| const p11 = { x: grid.p1Values[i + 1], y: grid.p2Values[j + 1], z: grid.vals[j + 1][i + 1] }; | |
| const p01 = { x: grid.p1Values[i], y: grid.p2Values[j + 1], z: grid.vals[j + 1][i] }; | |
| const hits = []; | |
| collectEdgeHit(hits, p00, p10, level); | |
| collectEdgeHit(hits, p10, p11, level); | |
| collectEdgeHit(hits, p11, p01, level); | |
| collectEdgeHit(hits, p01, p00, level); | |
| if (hits.length === 2) { | |
| xs.push(hits[0].x, hits[1].x, null); | |
| ys.push(hits[0].y, hits[1].y, null); | |
| zs.push(zFloor, zFloor, null); | |
| } else if (hits.length === 4) { | |
| xs.push(hits[0].x, hits[1].x, null, hits[2].x, hits[3].x, null); | |
| ys.push(hits[0].y, hits[1].y, null, hits[2].y, hits[3].y, null); | |
| zs.push(zFloor, zFloor, null, zFloor, zFloor, null); | |
| } | |
| } | |
| } | |
| } | |
| return { x: xs, y: ys, z: zs }; | |
| } | |
| function buildHover(meta, point) { | |
| return [ | |
| `Iteration: ${point.step}`, | |
| `${meta.p1Name}: ${point.p1.toFixed(4)}`, | |
| `${meta.p2Name}: ${point.p2.toFixed(4)}`, | |
| `Cost function: ${point.cost.toFixed(4)}`, | |
| ].join("<br>"); | |
| } | |
| function getFitSize() { | |
| const host = plotWrap.parentElement; | |
| const hostW = host ? host.getBoundingClientRect().width : window.innerWidth; | |
| const maxW = Math.max(320, Math.min(1000, hostW - 4)); | |
| if (window.innerWidth < 680) return { width: maxW, height: 390 }; | |
| if (window.innerWidth < 1024) return { width: Math.min(maxW, 920), height: 560 }; | |
| return { width: Math.min(maxW, 980), height: 640 }; | |
| } | |
| function setPlotSize(rawW, rawH, forceResize = false) { | |
| const host = plotWrap.parentElement; | |
| const hostMaxW = host ? host.getBoundingClientRect().width - 4 : rawW; | |
| const inFullscreen = document.fullscreenElement === plotWrap; | |
| const minW = 320; | |
| const minH = 300; | |
| const maxW = inFullscreen ? Math.min(1800, window.innerWidth - 24) : Math.max(minW, hostMaxW); | |
| const maxH = inFullscreen ? Math.min(1200, window.innerHeight - 24) : Math.min(900, window.innerHeight - 140); | |
| const w = Math.round(Math.max(minW, Math.min(maxW, rawW))); | |
| const h = Math.round(Math.max(minH, Math.min(maxH, rawH))); | |
| const rect = plotWrap.getBoundingClientRect(); | |
| const widthChanged = Math.abs(Math.round(rect.width) - w) > RESIZE_EPSILON; | |
| const heightChanged = Math.abs(Math.round(rect.height) - h) > RESIZE_EPSILON; | |
| if (widthChanged || heightChanged) { | |
| plotWrap.style.width = `${w}px`; | |
| plotWrap.style.height = `${h}px`; | |
| debugResizeLog("setPlotSize.apply", { width: w, height: h, forceResize }); | |
| } | |
| if (forceResize || widthChanged || heightChanged) { | |
| queuePlotResize(true); | |
| } | |
| } | |
| function fitPlotSize(forceResize = false) { | |
| const size = getFitSize(); | |
| setPlotSize(size.width, size.height, forceResize); | |
| } | |
| function queuePlotResize(force = false) { | |
| if (!state.plotReady || !hasPlotly()) return; | |
| const width = Math.round(plotWrap.clientWidth); | |
| const height = Math.round(plotWrap.clientHeight); | |
| if (width <= 0 || height <= 0) return; | |
| if (!force && state.lastPlotSize) { | |
| const dw = Math.abs(width - state.lastPlotSize.width); | |
| const dh = Math.abs(height - state.lastPlotSize.height); | |
| if (dw <= RESIZE_EPSILON && dh <= RESIZE_EPSILON) return; | |
| } | |
| state.lastPlotSize = { width, height }; | |
| debugResizeLog("queuePlotResize", { width, height, force }); | |
| if (state.resizeRaf) { | |
| cancelAnimationFrame(state.resizeRaf); | |
| } | |
| state.resizeRaf = requestAnimationFrame(() => { | |
| state.resizeRaf = null; | |
| debugResizeLog("Plotly.relayout(size)", { width, height }); | |
| window.Plotly.relayout(plotEl, { width, height }); | |
| }); | |
| } | |
| function toggleFullscreen() { | |
| if (document.fullscreenElement === plotWrap) { | |
| document.exitFullscreen(); | |
| return; | |
| } | |
| if (plotWrap.requestFullscreen) plotWrap.requestFullscreen(); | |
| } | |
| function updateStepSlider() { | |
| const max = Math.max(0, state.trajectory.length - 1); | |
| controls.stepSlider.max = String(max); | |
| controls.stepSlider.value = String(Math.min(state.stepIndex, max)); | |
| labels.stepLabel.textContent = `${Math.min(state.stepIndex, max)} / ${max}`; | |
| } | |
| function updateModelDefinition(caseKey) { | |
| const meta = caseMeta[caseKey]; | |
| modelDefinition.innerHTML = ` | |
| <p><strong>Model:</strong> ${meta.model}</p> | |
| <p>${meta.explain[0]}</p> | |
| <p>${meta.explain[1]}</p> | |
| ${meta.explain[2] ? `<p>${meta.explain[2]}</p>` : ""} | |
| `; | |
| } | |
| function updateSummary(caseKey) { | |
| const meta = caseMeta[caseKey]; | |
| if (state.trajectory.length === 0) { | |
| landscapeSummary.innerHTML = ` | |
| <span class="landscape-pill">Start cost: --</span> | |
| <span class="landscape-pill">Current cost: --</span> | |
| <span class="landscape-pill">Final cost: --</span> | |
| <span class="landscape-pill">Reduction: --</span> | |
| <span class="landscape-pill">Iterations: 0 / 0</span> | |
| `; | |
| return; | |
| } | |
| const idx = Math.min(state.stepIndex, state.trajectory.length - 1); | |
| const start = state.trajectory[0]; | |
| const current = state.trajectory[idx]; | |
| const final = state.trajectory[state.trajectory.length - 1]; | |
| const reduction = ((start.cost - final.cost) / Math.max(1e-9, start.cost)) * 100; | |
| landscapeSummary.innerHTML = ` | |
| <span class="landscape-pill">Start cost: ${start.cost.toFixed(4)}</span> | |
| <span class="landscape-pill">Current cost: ${current.cost.toFixed(4)}</span> | |
| <span class="landscape-pill">Final cost: ${final.cost.toFixed(4)}</span> | |
| <span class="landscape-pill">Reduction: ${reduction.toFixed(1)}%</span> | |
| <span class="landscape-pill">Iterations: ${idx} / ${state.trajectory.length - 1}</span> | |
| <span class="landscape-pill">${meta.p1Name}: ${current.p1.toFixed(3)}, ${meta.p2Name}: ${current.p2.toFixed(3)}</span> | |
| `; | |
| } | |
| function updateInfoChips(caseKey, grid) { | |
| const meta = caseMeta[caseKey]; | |
| if (grid.clipMax < grid.max - 1e-9) { | |
| clipInfo.textContent = `Surface clipping (visual only): J <= ${formatCost(grid.clipMax)} (true max ${formatCost(grid.max)})`; | |
| } else { | |
| clipInfo.textContent = "Surface clipping: none"; | |
| } | |
| minimumInfo.textContent = `Lowest shown point: ${meta.p1Name}=${grid.minPoint.p1.toFixed(3)}, ${meta.p2Name}=${grid.minPoint.p2.toFixed(3)}, J=${grid.minPoint.cost.toFixed(4)}`; | |
| if (state.unstableWarning) { | |
| stabilityWarning.textContent = state.unstableWarning; | |
| stabilityWarning.classList.add("warning"); | |
| } else { | |
| stabilityWarning.textContent = "Stability: normal"; | |
| stabilityWarning.classList.remove("warning"); | |
| } | |
| } | |
| function renderStats(caseKey) { | |
| const meta = caseMeta[caseKey]; | |
| const m = state.datasets[caseKey].length; | |
| if (state.trajectory.length === 0) { | |
| stats.innerHTML = ` | |
| <span>Model case: ${meta.p1Name}, ${meta.p2Name}</span> | |
| <span>Samples: ${m}</span> | |
| <span>No trajectory yet. Click Run Gradient Descent.</span> | |
| <span>Current step: 0</span> | |
| <span>${meta.p1Label}: --</span> | |
| <span>${meta.p2Label}: --</span> | |
| `; | |
| return; | |
| } | |
| const idx = Math.min(state.stepIndex, state.trajectory.length - 1); | |
| const current = state.trajectory[idx]; | |
| stats.innerHTML = ` | |
| <span>Model case: ${meta.p1Name}, ${meta.p2Name}</span> | |
| <span>Samples: ${m}</span> | |
| <span>Current step: ${idx}</span> | |
| <span>Cost function: ${current.cost.toFixed(4)}</span> | |
| <span>${meta.p1Label}: ${current.p1.toFixed(4)}</span> | |
| <span>${meta.p2Label}: ${current.p2.toFixed(4)}</span> | |
| `; | |
| } | |
| function renderLandscape(caseKey, grid) { | |
| if (!hasPlotly()) { | |
| showPlotFallback("Interactive 3D view unavailable (Plotly not loaded). Other page controls remain active."); | |
| state.plotReady = false; | |
| return; | |
| } | |
| const meta = caseMeta[caseKey]; | |
| const view = controls.landscapeView.value; | |
| const idx = Math.min(state.stepIndex, Math.max(0, state.trajectory.length - 1)); | |
| const plotWidth = Math.round(plotWrap.clientWidth); | |
| const plotHeight = Math.round(plotWrap.clientHeight); | |
| const showSurface = controls.toggleSurface.checked; | |
| const showContours = controls.toggleContours.checked; | |
| const showTrajectory = controls.toggleTrajectory.checked; | |
| const showCurrent = controls.toggleCurrent.checked; | |
| const fullPath = state.trajectory; | |
| const current = fullPath[idx]; | |
| const start = fullPath[0]; | |
| const final = fullPath[fullPath.length - 1]; | |
| const traces = []; | |
| if (view === "contour2d") { | |
| traces.push({ | |
| type: "contour", | |
| x: grid.p1Values, | |
| y: grid.p2Values, | |
| z: grid.vals, | |
| ncontours: 16, | |
| contours: { | |
| coloring: showSurface ? "heatmap" : "lines", | |
| }, | |
| colorscale: "Viridis", | |
| showscale: showSurface, | |
| visible: showSurface || showContours, | |
| colorbar: { title: "Cost function", thickness: 14 }, | |
| hovertemplate: `${meta.p1Name}: %{x:.4f}<br>${meta.p2Name}: %{y:.4f}<br>Cost function: %{z:.4f}<extra></extra>`, | |
| }); | |
| if (showTrajectory && fullPath.length > 0) { | |
| traces.push({ | |
| type: "scatter", | |
| mode: "lines+markers", | |
| x: fullPath.map((p) => p.p1), | |
| y: fullPath.map((p) => p.p2), | |
| customdata: fullPath.map((p) => p.step), | |
| line: { color: "#d8534f", width: 3 }, | |
| marker: { size: 4, color: "#9a3412" }, | |
| text: fullPath.map((p) => buildHover(meta, p)), | |
| hovertemplate: "%{text}<extra></extra>", | |
| }); | |
| traces.push({ | |
| type: "scatter", | |
| mode: "markers+text", | |
| x: [start.p1], | |
| y: [start.p2], | |
| customdata: [start.step], | |
| marker: { size: 10, color: "#0a8f7b", line: { color: "#102a32", width: 1 } }, | |
| text: ["Start"], | |
| textposition: "top center", | |
| hovertemplate: `${buildHover(meta, start)}<extra></extra>`, | |
| }); | |
| traces.push({ | |
| type: "scatter", | |
| mode: "markers+text", | |
| x: [final.p1], | |
| y: [final.p2], | |
| customdata: [final.step], | |
| marker: { size: 10, color: "#dd5e2f", line: { color: "#102a32", width: 1 } }, | |
| text: ["Current solution"], | |
| textposition: "top center", | |
| hovertemplate: `${buildHover(meta, final)}<extra></extra>`, | |
| }); | |
| } | |
| if (showCurrent && fullPath.length > 0) { | |
| traces.push({ | |
| type: "scatter", | |
| mode: "markers+text", | |
| x: [current.p1], | |
| y: [current.p2], | |
| customdata: [current.step], | |
| marker: { size: 10, color: "#111827", line: { color: "#f2b84b", width: 2 } }, | |
| text: [`Step ${idx}`], | |
| textposition: "bottom center", | |
| hovertemplate: `${buildHover(meta, current)}<extra></extra>`, | |
| }); | |
| } | |
| const layout2d = { | |
| autosize: false, | |
| width: plotWidth, | |
| height: plotHeight, | |
| margin: { l: 58, r: 16, t: 8, b: 50 }, | |
| xaxis: { title: meta.p1Label }, | |
| yaxis: { title: meta.p2Label }, | |
| showlegend: false, | |
| uirevision: "logistic-contour", | |
| paper_bgcolor: "#fff", | |
| plot_bgcolor: "#fff", | |
| }; | |
| const config2d = { | |
| responsive: false, | |
| displaylogo: false, | |
| scrollZoom: true, | |
| modeBarButtonsToRemove: ["lasso2d", "select2d"], | |
| }; | |
| const p2d = state.plotReady | |
| ? window.Plotly.react(plotEl, traces, layout2d, config2d) | |
| : window.Plotly.newPlot(plotEl, traces, layout2d, config2d); | |
| p2d.then(() => { | |
| if (!state.plotReady) { | |
| state.plotReady = true; | |
| state.lastPlotSize = null; | |
| bindPlotClick(); | |
| queuePlotResize(true); | |
| } | |
| }); | |
| return; | |
| } | |
| const clippedSurface = grid.vals.map((row) => row.map((v) => Math.min(v, grid.clipMax))); | |
| traces.push({ | |
| type: "surface", | |
| x: grid.p1Values, | |
| y: grid.p2Values, | |
| z: clippedSurface, | |
| opacity: 0.84, | |
| colorscale: "Viridis", | |
| showscale: showSurface, | |
| visible: showSurface, | |
| colorbar: { title: "Cost function", thickness: 14 }, | |
| hovertemplate: `${meta.p1Name}: %{x:.4f}<br>${meta.p2Name}: %{y:.4f}<br>Cost function: %{z:.4f}<extra></extra>`, | |
| }); | |
| const contour3d = buildContourSegments3D(grid); | |
| traces.push({ | |
| type: "scatter3d", | |
| mode: "lines", | |
| x: contour3d.x, | |
| y: contour3d.y, | |
| z: contour3d.z, | |
| line: { color: "rgba(16,54,76,0.7)", width: 3 }, | |
| visible: showContours || view === "surface3dContours", | |
| hoverinfo: "skip", | |
| }); | |
| if (showTrajectory && fullPath.length > 0) { | |
| traces.push({ | |
| type: "scatter3d", | |
| mode: "lines+markers", | |
| x: fullPath.map((p) => p.p1), | |
| y: fullPath.map((p) => p.p2), | |
| z: fullPath.map((p) => Math.min(p.cost, grid.clipMax)), | |
| customdata: fullPath.map((p) => p.step), | |
| line: { color: "#d8534f", width: 6 }, | |
| marker: { size: 4, color: "#9a3412" }, | |
| text: fullPath.map((p) => buildHover(meta, p)), | |
| hovertemplate: "%{text}<extra></extra>", | |
| }); | |
| traces.push({ | |
| type: "scatter3d", | |
| mode: "markers+text", | |
| x: [start.p1], | |
| y: [start.p2], | |
| z: [Math.min(start.cost, grid.clipMax)], | |
| customdata: [start.step], | |
| marker: { size: 10, color: "#0a8f7b", line: { color: "#102a32", width: 1 } }, | |
| text: ["Start"], | |
| textposition: "top center", | |
| hovertemplate: `${buildHover(meta, start)}<extra></extra>`, | |
| }); | |
| traces.push({ | |
| type: "scatter3d", | |
| mode: "markers+text", | |
| x: [final.p1], | |
| y: [final.p2], | |
| z: [Math.min(final.cost, grid.clipMax)], | |
| customdata: [final.step], | |
| marker: { size: 10, color: "#dd5e2f", line: { color: "#102a32", width: 1 } }, | |
| text: ["Current solution"], | |
| textposition: "top center", | |
| hovertemplate: `${buildHover(meta, final)}<extra></extra>`, | |
| }); | |
| } | |
| if (showCurrent && fullPath.length > 0) { | |
| traces.push({ | |
| type: "scatter3d", | |
| mode: "markers+text", | |
| x: [current.p1], | |
| y: [current.p2], | |
| z: [Math.min(current.cost, grid.clipMax)], | |
| customdata: [current.step], | |
| marker: { size: 9, color: "#111827", line: { color: "#f2b84b", width: 2 } }, | |
| text: [`Step ${idx}`], | |
| textposition: "bottom center", | |
| hovertemplate: `${buildHover(meta, current)}<extra></extra>`, | |
| }); | |
| } | |
| const layout3d = { | |
| autosize: false, | |
| width: plotWidth, | |
| height: plotHeight, | |
| margin: { l: 0, r: 0, t: 8, b: 0 }, | |
| scene: { | |
| xaxis: { title: meta.p1Label, range: [grid.p1Min, grid.p1Max], showspikes: false }, | |
| yaxis: { title: meta.p2Label, range: [grid.p2Min, grid.p2Max], showspikes: false }, | |
| zaxis: { title: "Cost function J(theta)", range: [grid.min, grid.clipMax], showspikes: false }, | |
| aspectmode: "manual", | |
| aspectratio: { x: 1.14, y: 1.12, z: 0.82 }, | |
| camera: state.camera3d || cloneCamera(DEFAULT_CAMERA), | |
| dragmode: "turntable", | |
| }, | |
| showlegend: false, | |
| uirevision: "logistic-3d", | |
| paper_bgcolor: "#fff", | |
| }; | |
| const config3d = { | |
| responsive: false, | |
| displaylogo: false, | |
| scrollZoom: true, | |
| doubleClick: "reset", | |
| modeBarButtonsToRemove: ["lasso2d", "select2d"], | |
| }; | |
| const p3d = state.plotReady | |
| ? window.Plotly.react(plotEl, traces, layout3d, config3d) | |
| : window.Plotly.newPlot(plotEl, traces, layout3d, config3d); | |
| p3d.then(() => { | |
| if (!state.plotReady) { | |
| state.plotReady = true; | |
| state.lastPlotSize = null; | |
| bindPlotClick(); | |
| plotEl.on("plotly_relayout", (ev) => { | |
| if (ev && ev["scene.camera"]) { | |
| state.camera3d = cloneCamera(ev["scene.camera"]); | |
| } | |
| debugResizeLog("plotly_relayout", { | |
| hasCamera: !!(ev && ev["scene.camera"]), | |
| }); | |
| }); | |
| queuePlotResize(true); | |
| } | |
| }); | |
| } | |
| function bindPlotClick() { | |
| if (!plotEl || !plotEl.on) return; | |
| plotEl.on("plotly_click", (ev) => { | |
| if (!ev || !ev.points || ev.points.length === 0) return; | |
| const pt = ev.points[0]; | |
| const raw = pt.customdata; | |
| let step = null; | |
| if (typeof raw === "number") step = raw; | |
| if (Array.isArray(raw) && typeof raw[0] === "number") step = raw[0]; | |
| if (Number.isInteger(step) && step >= 0 && step < state.trajectory.length) { | |
| state.stepIndex = step; | |
| render(); | |
| } | |
| }); | |
| } | |
| function drawClassifier(caseKey) { | |
| const parent = classifierCanvas.parentElement; | |
| const width = Math.max(320, Math.round(parent.getBoundingClientRect().width)); | |
| const height = 310; | |
| if (classifierCanvas.width !== width || classifierCanvas.height !== height) { | |
| classifierCanvas.width = width; | |
| classifierCanvas.height = height; | |
| } | |
| const ctx = classifierCtx; | |
| ctx.clearRect(0, 0, width, height); | |
| ctx.fillStyle = "#fff"; | |
| ctx.fillRect(0, 0, width, height); | |
| const pad = 42; | |
| if (state.trajectory.length === 0) { | |
| ctx.fillStyle = "#2a3d46"; | |
| ctx.font = "14px 'Avenir Next', sans-serif"; | |
| ctx.fillText("Run Gradient Descent to view classifier changes by iteration.", 22, 42); | |
| return; | |
| } | |
| const idx = Math.min(state.stepIndex, state.trajectory.length - 1); | |
| const step = state.trajectory[idx]; | |
| const data = state.datasets[caseKey]; | |
| if (caseKey === "case1") { | |
| const xs = data.map((d) => d.x); | |
| const xMin = Math.min(...xs) - 0.6; | |
| const xMax = Math.max(...xs) + 0.6; | |
| const toX = (x) => pad + ((x - xMin) / (xMax - xMin || 1)) * (width - 2 * pad); | |
| const toY = (p) => height - pad - p * (height - 2 * pad); | |
| ctx.strokeStyle = "#7b8f98"; | |
| ctx.lineWidth = 1.1; | |
| 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.setLineDash([5, 4]); | |
| ctx.strokeStyle = "#8aa0ad"; | |
| ctx.beginPath(); | |
| ctx.moveTo(pad, toY(0.5)); | |
| ctx.lineTo(width - pad, toY(0.5)); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = "#0a8f7b"; | |
| for (const row of data) { | |
| const yJ = row.y === 1 ? 0.93 : 0.07; | |
| ctx.beginPath(); | |
| ctx.arc(toX(row.x), toY(yJ), 3.4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.strokeStyle = "#d8534f"; | |
| ctx.lineWidth = 2.6; | |
| ctx.beginPath(); | |
| for (let i = 0; i <= 120; i += 1) { | |
| const x = xMin + (i / 120) * (xMax - xMin); | |
| const p = sigmoid(step.p1 + step.p2 * x); | |
| const sx = toX(x); | |
| const sy = toY(p); | |
| if (i === 0) ctx.moveTo(sx, sy); | |
| else ctx.lineTo(sx, sy); | |
| } | |
| ctx.stroke(); | |
| if (Math.abs(step.p2) > 1e-9) { | |
| const xb = -step.p1 / step.p2; | |
| if (xb >= xMin && xb <= xMax) { | |
| ctx.setLineDash([4, 4]); | |
| ctx.strokeStyle = "#111827"; | |
| ctx.beginPath(); | |
| ctx.moveTo(toX(xb), pad); | |
| ctx.lineTo(toX(xb), height - pad); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| } | |
| ctx.fillStyle = "#334b56"; | |
| ctx.font = "13px 'Avenir Next', sans-serif"; | |
| ctx.fillText("x", width - pad - 10, height - pad + 20); | |
| ctx.fillText("Probability", pad - 28, pad - 10); | |
| } else { | |
| const x1s = data.map((d) => d.x1); | |
| const x2s = data.map((d) => d.x2); | |
| const x1Min = Math.min(...x1s) - 0.7; | |
| const x1Max = Math.max(...x1s) + 0.7; | |
| const x2Min = Math.min(...x2s) - 0.7; | |
| const x2Max = Math.max(...x2s) + 0.7; | |
| const toX = (x) => pad + ((x - x1Min) / (x1Max - x1Min || 1)) * (width - 2 * pad); | |
| const toY = (y) => height - pad - ((y - x2Min) / (x2Max - x2Min || 1)) * (height - 2 * pad); | |
| ctx.strokeStyle = "#7b8f98"; | |
| ctx.lineWidth = 1.1; | |
| 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(); | |
| for (const row of data) { | |
| ctx.fillStyle = row.y === 1 ? "#0a8f7b" : "#d8534f"; | |
| ctx.beginPath(); | |
| ctx.arc(toX(row.x1), toY(row.x2), 3.2, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.strokeStyle = "#111827"; | |
| ctx.lineWidth = 2.4; | |
| if (Math.abs(step.p2) > 1e-9) { | |
| const xa = x1Min; | |
| const xb = x1Max; | |
| const ya = -(step.p1 / step.p2) * xa; | |
| const yb = -(step.p1 / step.p2) * xb; | |
| ctx.beginPath(); | |
| ctx.moveTo(toX(xa), toY(ya)); | |
| ctx.lineTo(toX(xb), toY(yb)); | |
| ctx.stroke(); | |
| } else { | |
| ctx.beginPath(); | |
| ctx.moveTo(toX(0), pad); | |
| ctx.lineTo(toX(0), height - pad); | |
| ctx.stroke(); | |
| } | |
| ctx.fillStyle = "#334b56"; | |
| ctx.font = "13px 'Avenir Next', sans-serif"; | |
| ctx.fillText("x1", width - pad - 12, height - pad + 20); | |
| ctx.fillText("x2", pad - 22, pad - 10); | |
| } | |
| } | |
| function render() { | |
| const caseKey = controls.caseSelect.value; | |
| const meta = caseMeta[caseKey]; | |
| labels.formula.textContent = meta.formula; | |
| labels.lr.textContent = Number(controls.lr.value).toFixed(3); | |
| labels.iters.textContent = controls.iters.value; | |
| labels.p1.textContent = Number(controls.p1.value).toFixed(2); | |
| labels.p2.textContent = Number(controls.p2.value).toFixed(2); | |
| labels.speed.textContent = controls.speed.value; | |
| updateModelDefinition(caseKey); | |
| updateStepSlider(); | |
| const grid = getGrid(caseKey); | |
| updateInfoChips(caseKey, grid); | |
| updateSummary(caseKey); | |
| renderLandscape(caseKey, grid); | |
| renderStats(caseKey); | |
| drawClassifier(caseKey); | |
| } | |
| function stopAnimation() { | |
| if (!state.timer) { | |
| controls.autoBtn.textContent = "Automatic Run"; | |
| return; | |
| } | |
| clearInterval(state.timer); | |
| state.timer = null; | |
| controls.autoBtn.textContent = "Automatic Run"; | |
| } | |
| function runTrajectory() { | |
| stopAnimation(); | |
| const caseKey = controls.caseSelect.value; | |
| const start = [Number(controls.p1.value), Number(controls.p2.value)]; | |
| const lr = Number(controls.lr.value); | |
| const iters = Number(controls.iters.value); | |
| const { path, unstable } = buildTrajectory(caseKey, start, lr, iters); | |
| state.trajectory = path; | |
| state.stepIndex = 0; | |
| state.unstableWarning = unstable | |
| ? "The learning rate may be too large; gradient descent became unstable." | |
| : ""; | |
| state.gridCache[caseKey] = null; | |
| render(); | |
| } | |
| function nextStep() { | |
| if (state.trajectory.length === 0) { | |
| runTrajectory(); | |
| return; | |
| } | |
| if (state.stepIndex < state.trajectory.length - 1) { | |
| state.stepIndex += 1; | |
| render(); | |
| } | |
| } | |
| function autoRun() { | |
| if (state.timer) { | |
| stopAnimation(); | |
| return; | |
| } | |
| if (state.trajectory.length === 0) runTrajectory(); | |
| controls.autoBtn.textContent = "Stop Auto"; | |
| state.timer = setInterval(() => { | |
| if (state.stepIndex >= state.trajectory.length - 1) { | |
| stopAnimation(); | |
| return; | |
| } | |
| state.stepIndex += 1; | |
| render(); | |
| }, Number(controls.speed.value)); | |
| } | |
| function restartPath() { | |
| stopAnimation(); | |
| if (state.trajectory.length > 0) { | |
| state.stepIndex = 0; | |
| } | |
| render(); | |
| } | |
| function clearPath() { | |
| stopAnimation(); | |
| state.trajectory = []; | |
| state.stepIndex = 0; | |
| state.unstableWarning = ""; | |
| state.gridCache = { case1: null, case2: null }; | |
| render(); | |
| } | |
| function applyCaseDefaults(caseKey) { | |
| if (caseKey === "case1") { | |
| controls.p1.value = -4.0; | |
| controls.p2.value = 4.0; | |
| controls.lr.value = 0.1; | |
| } else { | |
| controls.p1.value = -2.2; | |
| controls.p2.value = -2.0; | |
| controls.lr.value = 0.1; | |
| } | |
| controls.lrInput.value = Number(controls.lr.value).toFixed(3); | |
| controls.iters.value = 80; | |
| controls.speed.value = 90; | |
| clearPath(); | |
| } | |
| function resetView() { | |
| if (!hasPlotly() || !state.plotReady) return; | |
| if (controls.landscapeView.value === "contour2d") { | |
| window.Plotly.relayout(plotEl, { | |
| "xaxis.autorange": true, | |
| "yaxis.autorange": true, | |
| }); | |
| return; | |
| } | |
| state.camera3d = cloneCamera(DEFAULT_CAMERA); | |
| window.Plotly.relayout(plotEl, { "scene.camera": state.camera3d }); | |
| } | |
| function bindEvents() { | |
| controls.lr.addEventListener("input", () => { | |
| controls.lrInput.value = Number(controls.lr.value).toFixed(3); | |
| render(); | |
| }); | |
| controls.lrInput.addEventListener("change", () => { | |
| let v = Number(controls.lrInput.value); | |
| if (!Number.isFinite(v)) v = 0.1; | |
| v = Math.max(0.001, Math.min(10.0, v)); | |
| controls.lr.value = v; | |
| controls.lrInput.value = v.toFixed(3); | |
| render(); | |
| }); | |
| for (const el of [controls.iters, controls.p1, controls.p2, controls.speed]) { | |
| el.addEventListener("input", render); | |
| } | |
| controls.caseSelect.addEventListener("change", () => { | |
| applyCaseDefaults(controls.caseSelect.value); | |
| }); | |
| controls.runBtn.addEventListener("click", runTrajectory); | |
| controls.stepBtn.addEventListener("click", nextStep); | |
| controls.autoBtn.addEventListener("click", autoRun); | |
| controls.restartBtn.addEventListener("click", restartPath); | |
| controls.clearBtn.addEventListener("click", clearPath); | |
| controls.regenBtn.addEventListener("click", () => { | |
| generateSyntheticData(); | |
| clearPath(); | |
| }); | |
| controls.landscapeView.addEventListener("change", render); | |
| controls.toggleSurface.addEventListener("change", render); | |
| controls.toggleContours.addEventListener("change", render); | |
| controls.toggleTrajectory.addEventListener("change", render); | |
| controls.toggleCurrent.addEventListener("change", render); | |
| controls.stepSlider.addEventListener("input", () => { | |
| const next = Number(controls.stepSlider.value); | |
| if (!Number.isFinite(next)) return; | |
| state.stepIndex = Math.max(0, Math.min(next, Math.max(0, state.trajectory.length - 1))); | |
| render(); | |
| }); | |
| controls.resetViewBtn.addEventListener("click", resetView); | |
| controls.plotFitBtn.addEventListener("click", () => fitPlotSize(true)); | |
| controls.plotLargerBtn.addEventListener("click", () => setPlotSize(plotWrap.clientWidth * 1.14, plotWrap.clientHeight * 1.14, true)); | |
| controls.plotSmallerBtn.addEventListener("click", () => setPlotSize(plotWrap.clientWidth / 1.14, plotWrap.clientHeight / 1.14, true)); | |
| controls.plotFullscreenBtn.addEventListener("click", toggleFullscreen); | |
| document.addEventListener("fullscreenchange", () => { | |
| controls.plotFullscreenBtn.textContent = document.fullscreenElement === plotWrap ? "Exit Fullscreen" : "Fullscreen"; | |
| debugResizeLog("fullscreenchange", { inFullscreen: document.fullscreenElement === plotWrap }); | |
| if (document.fullscreenElement === plotWrap) { | |
| setPlotSize(window.innerWidth - 24, window.innerHeight - 24, true); | |
| return; | |
| } | |
| const rect = plotWrap.getBoundingClientRect(); | |
| setPlotSize(rect.width, rect.height, false); | |
| }); | |
| window.addEventListener("resize", () => { | |
| debugResizeLog("window.resize"); | |
| if (document.fullscreenElement === plotWrap) { | |
| setPlotSize(window.innerWidth - 24, window.innerHeight - 24, false); | |
| } else { | |
| const rect = plotWrap.getBoundingClientRect(); | |
| setPlotSize(rect.width, rect.height, false); | |
| } | |
| drawClassifier(controls.caseSelect.value); | |
| }); | |
| } | |
| function init() { | |
| fitPlotSize(true); | |
| bindEvents(); | |
| generateSyntheticData(); | |
| applyCaseDefaults("case1"); | |
| if (!hasPlotly()) { | |
| showPlotFallback("Interactive 3D view unavailable (Plotly script did not load)."); | |
| } | |
| } | |
| init(); | |