Spaces:
Running
Running
| const PYODIDE_URL = "https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.mjs"; | |
| const RANGE = { min: -5, max: 5 }; | |
| const COLORS = { A: "#2d6cdf", B: "#e86f32" }; | |
| const REGION = { A: "rgba(45,108,223,.12)", B: "rgba(232,111,50,.12)" }; | |
| const BUILTIN_DATASETS = { | |
| "easy-linear": [ | |
| { x: -3.2, y: -2.0, label: "A" }, { x: -2.2, y: 0.1, label: "A" }, { x: -3.0, y: 2.3, label: "A" }, | |
| { x: 3.1, y: -2.1, label: "B" }, { x: 2.1, y: 0.2, label: "B" }, { x: 3.2, y: 2.2, label: "B" }, | |
| ], | |
| "hard-linear": [ | |
| { x: -3.4, y: -1.08, label: "A" }, { x: -0.2, y: 0.80, label: "A" }, { x: 3.2, y: 2.95, label: "A" }, | |
| { x: -3.2, y: -2.30, label: "B" }, { x: 0.2, y: -0.16, label: "B" }, { x: 3.4, y: 1.77, label: "B" }, | |
| ], | |
| nonlinear: [ | |
| { x: -0.7, y: -0.5, label: "A" }, { x: 0.7, y: -0.5, label: "A" }, { x: 0.0, y: 0.8, label: "A" }, | |
| { x: -3.2, y: -2.4, label: "B" }, { x: 3.2, y: -2.4, label: "B" }, { x: 0.0, y: 3.3, label: "B" }, | |
| ], | |
| }; | |
| const state = { points: structuredClone(BUILTIN_DATASETS['easy-linear']), datasetMode: "easy-linear", redoPoints: [], hoveredPoint: -1, label: "A", model: null, pendingUpdate: null, metrics: null, step: 0, permutation: [], cursor: 0, nextBatch: [], rngState: 42, history: [], viewIndex: -1, previewVersion: 0, previewPromise: null, busy: false, animating: false, ready: false }; | |
| const canvas = document.querySelector("#plot"); | |
| const ctx = canvas.getContext("2d"); | |
| const metricsCanvas = document.querySelector("#metrics-chart"); | |
| const metricsCtx = metricsCanvas.getContext("2d"); | |
| const ui = Object.fromEntries([...document.querySelectorAll("[id]")].map(el => [el.id, el])); | |
| function modelOptions() { | |
| return { | |
| loss: ui.loss.value, | |
| learning_rate: Number(ui["learning-rate"].value), | |
| batch_size: Math.max(1, Number.parseInt(ui["batch-size"].value, 10) || 1), | |
| batch_selection: ui["batch-selection"].value, | |
| random_state: randomSeed(), | |
| }; | |
| } | |
| function randomSeed() { return (Number.parseInt(ui["random-seed"].value, 10) || 0) >>> 0; } | |
| function resetModel(message = "Model reset. Take a step to begin training.") { | |
| state.model = null; state.pendingUpdate = null; state.metrics = null; state.step = 0; state.permutation = []; state.cursor = 0; state.nextBatch = []; state.history = []; state.viewIndex = -1; | |
| state.rngState = randomSeed(); | |
| queueNextBatch(); | |
| ui["step-count"].textContent = "Step 0"; | |
| renderView(); setMessage(message); updateControls(); refreshPreview(); | |
| } | |
| function setMessage(text, kind = "") { ui.message.textContent = text; ui.message.className = `message ${kind}`; } | |
| function hasBothClasses() { return state.points.some(p => p.label === "A") && state.points.some(p => p.label === "B"); } | |
| function updateControls() { | |
| ui.undo.disabled = state.points.length === 0 || state.busy || state.animating; | |
| ui.redo.disabled = state.redoPoints.length === 0 || state.busy || state.animating; | |
| ui.clear.disabled = state.points.length === 0 || state.busy || state.animating; | |
| ui["label-a"].disabled = state.busy || state.animating; ui["label-b"].disabled = state.busy || state.animating; | |
| for (const id of ["dataset-select", "loss", "learning-rate", "batch-size", "random-seed", "animation-speed", "batch-selection"]) ui[id].disabled = state.busy || state.animating; | |
| ui["history-prev"].disabled = state.viewIndex <= 0 || state.busy || state.animating; | |
| ui["history-reset"].disabled = state.viewIndex <= 0 || state.busy || state.animating; | |
| ui["history-next"].disabled = !state.ready || !hasBothClasses() || state.busy || state.animating; | |
| ui.animate.disabled = !state.ready || !hasBothClasses() || state.busy || state.animating; | |
| ui.pause.disabled = !state.animating; | |
| ui["point-count"].textContent = `${state.points.length} point${state.points.length === 1 ? "" : "s"}`; | |
| ui["empty-hint"].hidden = state.points.length > 0; | |
| //ui["plot-instructions"].textContent = state.datasetMode === "custom" ? "Click inside the plot to add a point with the selected label." : "Hover over a point to inspect its coordinates."; | |
| if (!hasBothClasses()) setMessage("Add at least one point from each class to begin."); | |
| } | |
| function shuffleIndices(count) { | |
| const values = Array.from({ length: count }, (_, i) => i); | |
| for (let i = values.length - 1; i > 0; i--) { const j = Math.floor(seededRandom() * (i + 1)); [values[i], values[j]] = [values[j], values[i]]; } | |
| return values; | |
| } | |
| function seededRandom() { | |
| state.rngState = (Math.imul(1664525, state.rngState) + 1013904223) >>> 0; | |
| return state.rngState / 4294967296; | |
| } | |
| function nextBatch() { | |
| const { batch_size, batch_selection } = modelOptions(); | |
| const size = Math.min(batch_size, state.points.length); | |
| if (batch_selection === "random") return shuffleIndices(state.points.length).slice(0, size); | |
| if (!state.permutation.length || state.cursor >= state.permutation.length) { state.permutation = shuffleIndices(state.points.length); state.cursor = 0; } | |
| const batch = state.permutation.slice(state.cursor, state.cursor + size); state.cursor += size; | |
| return batch; | |
| } | |
| function queueNextBatch() { | |
| state.nextBatch = hasBothClasses() ? nextBatch() : []; | |
| const count = state.nextBatch.length; | |
| ui["batch-summary"].textContent = count ? `Next batch: ${count} point${count === 1 ? "" : "s"}` : "No batch selected"; | |
| } | |
| function formatNumber(value) { | |
| if (Math.abs(value) < 0.0000005) return "0.0000"; | |
| if (Math.abs(value) >= 1000 || Math.abs(value) < 0.001) return value.toExponential(3); | |
| return value.toFixed(4); | |
| } | |
| function formatVector(vector) { return `[${vector.map(formatNumber).join(", ")}]`; } | |
| function visibleSnapshot() { | |
| return state.history[state.viewIndex] || { model: state.model, pendingUpdate: state.pendingUpdate, metrics: state.metrics, nextBatch: state.nextBatch, step: state.step }; | |
| } | |
| function renderParameters(snapshot = visibleSnapshot()) { | |
| ui["model-parameters"].textContent = snapshot.model | |
| ? `w_A = ${formatVector(snapshot.model.coef[0])}\nb_A = ${formatNumber(snapshot.model.intercept[0])}` | |
| : "w_A = —\nb_A = —"; | |
| ui["model-update"].textContent = snapshot.pendingUpdate | |
| ? `Δw_A = ${formatVector(snapshot.pendingUpdate.coef[0])}\nΔb_A = ${formatNumber(snapshot.pendingUpdate.intercept[0])}` | |
| : "Δw_A = —\nΔb_A = —"; | |
| } | |
| function captureSnapshot() { | |
| const snapshot = structuredClone({ model: state.model, pendingUpdate: state.pendingUpdate, metrics: state.metrics, nextBatch: state.nextBatch, step: state.step }); | |
| if (state.history.at(-1)?.step === state.step) state.history[state.history.length - 1] = snapshot; | |
| else state.history.push(snapshot); | |
| state.viewIndex = state.history.length - 1; | |
| renderView(); | |
| } | |
| function renderView() { | |
| const snapshot = visibleSnapshot(); | |
| renderParameters(snapshot); | |
| ui["step-count"].textContent = `Step ${snapshot.step}`; | |
| ui["history-position"].textContent = `Step ${snapshot.step} of ${Math.max(0, state.history.length - 1)}`; | |
| const count = snapshot.nextBatch.length; | |
| ui["batch-summary"].textContent = count ? `Next batch: ${count} point${count === 1 ? "" : "s"}` : "No batch selected"; | |
| updateControls(); draw(); drawMetrics(); | |
| } | |
| async function refreshPreview(reportError = true) { | |
| const version = ++state.previewVersion; | |
| if (!state.ready || !hasBothClasses() || !state.nextBatch.length) { renderView(); return; } | |
| state.previewPromise = (async () => { | |
| try { | |
| if (!state.model) { | |
| const pyOptions = state.pyodide.toPy(modelOptions()); | |
| state.pyodide.globals.set("model_options", pyOptions); | |
| try { state.pyodide.runPython("model = SGDClassifier(**model_options)"); } | |
| finally { pyOptions.destroy(); state.pyodide.globals.delete("model_options"); } | |
| } | |
| const X = state.nextBatch.map(i => [state.points[i].x, state.points[i].y]); | |
| const y = state.nextBatch.map(i => state.points[i].label); | |
| const allX = state.points.map(point => [point.x, point.y]); | |
| const allY = state.points.map(point => point.label); | |
| const pyX = state.pyodide.toPy(X), pyY = state.pyodide.toPy(y); | |
| const pyAllX = state.pyodide.toPy(allX), pyAllY = state.pyodide.toPy(allY); | |
| state.pyodide.globals.set("preview_X", pyX); state.pyodide.globals.set("preview_y", pyY); | |
| state.pyodide.globals.set("all_X", pyAllX); state.pyodide.globals.set("all_y", pyAllY); | |
| try { | |
| const result = state.pyodide.runPython(` | |
| preview_coef, preview_intercept = model.get_update(preview_X, preview_y, classes=["A", "B"]) | |
| metrics = model.evaluate(all_X, all_y) | |
| {"coef": model.coef_.tolist(), "intercept": model.intercept_.tolist(), | |
| "update_coef": preview_coef.tolist(), "update_intercept": preview_intercept.tolist(), | |
| "loss": metrics["loss"], "accuracy": metrics["accuracy"]} | |
| `); | |
| const values = result.toJs({ dict_converter: Object.fromEntries }); result.destroy(); | |
| if (version !== state.previewVersion) return; | |
| state.model = { coef: values.coef, intercept: values.intercept }; | |
| state.pendingUpdate = { coef: values.update_coef, intercept: values.update_intercept }; | |
| state.metrics = { loss: values.loss, accuracy: values.accuracy }; | |
| captureSnapshot(); | |
| } finally { | |
| pyX.destroy(); pyY.destroy(); pyAllX.destroy(); pyAllY.destroy(); | |
| for (const name of ["preview_X", "preview_y", "all_X", "all_y"]) state.pyodide.globals.delete(name); | |
| } | |
| } catch (error) { | |
| if (version === state.previewVersion) { state.pendingUpdate = null; renderView(); if (reportError) setMessage(error instanceof Error ? error.message : String(error), "error"); } | |
| } | |
| })(); | |
| return state.previewPromise; | |
| } | |
| async function initializeRuntime() { | |
| try { | |
| const { loadPyodide } = await import(PYODIDE_URL); | |
| const pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.26.1/full/" }); | |
| await pyodide.loadPackage("numpy"); | |
| const classifierUrl = new URL("sgd_classifier.py", window.location.href); | |
| classifierUrl.searchParams.set("v", Date.now().toString()); | |
| const source = await fetch(classifierUrl, { cache: "no-store" }).then(response => { if (!response.ok) throw new Error("Classifier source could not be loaded."); return response.text(); }); | |
| pyodide.FS.writeFile("sgd_classifier.py", source); | |
| pyodide.runPython("from sgd_classifier import SGDClassifier"); | |
| state.pyodide = pyodide; state.ready = true; | |
| ui["runtime-status"].className = "status ready"; ui["runtime-status"].innerHTML = "<span></span>Python runtime ready"; | |
| updateControls(); refreshPreview(); | |
| } catch (error) { | |
| ui["runtime-status"].className = "status error"; ui["runtime-status"].innerHTML = "<span></span>Runtime failed"; | |
| setMessage(error instanceof Error ? error.message : String(error), "error"); | |
| } | |
| } | |
| async function trainOneStep(fromAnimation = false) { | |
| if (!fromAnimation && ui["history-next"].disabled) return false; | |
| if (state.viewIndex < state.history.length - 1) { state.viewIndex += 1; renderView(); return true; } | |
| state.busy = true; updateControls(); ui["history-next"].querySelector(".button-text").textContent = "Training…"; | |
| try { | |
| if (state.previewPromise) await state.previewPromise; | |
| if (!state.model || !state.pendingUpdate) throw new Error("The parameter update is not ready yet."); | |
| const indices = state.nextBatch.slice(); | |
| const X = indices.map(i => [state.points[i].x, state.points[i].y]); | |
| const y = indices.map(i => state.points[i].label); | |
| const pyX = state.pyodide.toPy(X), pyY = state.pyodide.toPy(y); | |
| state.pyodide.globals.set("batch_X", pyX); state.pyodide.globals.set("batch_y", pyY); | |
| try { | |
| const result = state.pyodide.runPython(` | |
| model.train_step(batch_X, batch_y, classes=["A", "B"]) | |
| {"coef": model.coef_.tolist(), "intercept": model.intercept_.tolist()} | |
| `); | |
| state.model = result.toJs({ dict_converter: Object.fromEntries }); result.destroy(); | |
| } finally { pyX.destroy(); pyY.destroy(); state.pyodide.globals.delete("batch_X"); state.pyodide.globals.delete("batch_y"); } | |
| state.step += 1; | |
| queueNextBatch(); | |
| await refreshPreview(false); | |
| setMessage(`Completed SGD step ${state.step}. The next update is shown.`, "success"); renderView(); | |
| return true; | |
| } catch (error) { setMessage(error instanceof Error ? error.message : String(error), "error"); return false; } | |
| finally { state.busy = false; ui["history-next"].querySelector(".button-text").textContent = "Next"; updateControls(); } | |
| } | |
| async function startAnimation() { | |
| if (ui.animate.disabled) return; | |
| state.animating = true; updateControls(); setMessage("Animation running…", "success"); | |
| while (state.animating) { | |
| const succeeded = await trainOneStep(true); | |
| if (!succeeded) { state.animating = false; break; } | |
| await new Promise(resolve => setTimeout(resolve, Number(ui["animation-speed"].value))); | |
| } | |
| updateControls(); | |
| if (!state.animating && !ui.message.classList.contains("error")) setMessage("Animation paused."); | |
| } | |
| function pauseAnimation() { state.animating = false; updateControls(); } | |
| function drawMetrics() { | |
| const rect = metricsCanvas.getBoundingClientRect(); | |
| if (!rect.width || !rect.height) return; | |
| const ratio = window.devicePixelRatio || 1; | |
| metricsCanvas.width = Math.round(rect.width * ratio); metricsCanvas.height = Math.round(rect.height * ratio); | |
| metricsCtx.setTransform(ratio, 0, 0, ratio, 0, 0); metricsCtx.clearRect(0, 0, rect.width, rect.height); | |
| const data = state.history.filter(snapshot => snapshot.metrics); | |
| const pad = { left: 34, right: 34, top: 10, bottom: 22 }; | |
| const width = rect.width - pad.left - pad.right, height = rect.height - pad.top - pad.bottom; | |
| const bottom = pad.top + height; | |
| metricsCtx.font = "9px system-ui"; metricsCtx.fillStyle = "#77847d"; metricsCtx.strokeStyle = "#e4e9e5"; metricsCtx.lineWidth = 1; | |
| for (let fraction = 0; fraction <= 1; fraction += 0.5) { | |
| const y = pad.top + height * fraction; metricsCtx.beginPath(); metricsCtx.moveTo(pad.left,y); metricsCtx.lineTo(pad.left+width,y); metricsCtx.stroke(); | |
| } | |
| if (!data.length) { metricsCtx.textAlign="center"; metricsCtx.fillText("Metrics appear after initialization",rect.width/2,rect.height/2); return; } | |
| const maxLoss = Math.max(...data.map(snapshot => snapshot.metrics.loss), 1e-9) * 1.08; | |
| const pointX = index => data.length === 1 ? pad.left + width / 2 : pad.left + index / (data.length - 1) * width; | |
| const lossY = value => bottom - value / maxLoss * height; | |
| const accuracyY = value => bottom - value * height; | |
| metricsCtx.textAlign="right"; metricsCtx.fillStyle="#8b5cf6"; metricsCtx.fillText(maxLoss.toFixed(2),pad.left-5,pad.top+3); metricsCtx.fillText("0",pad.left-5,bottom+3); | |
| metricsCtx.textAlign="left"; metricsCtx.fillStyle="#159267"; metricsCtx.fillText("100%",pad.left+width+5,pad.top+3); metricsCtx.fillText("0%",pad.left+width+5,bottom+3); | |
| metricsCtx.textAlign="center"; metricsCtx.fillStyle="#77847d"; metricsCtx.fillText("step",pad.left+width/2,rect.height-3); | |
| const drawSeries = (color, getY) => { | |
| metricsCtx.strokeStyle=color; metricsCtx.lineWidth=2; metricsCtx.beginPath(); | |
| data.forEach((snapshot,index)=>{const x=pointX(index),y=getY(snapshot.metrics);if(index)metricsCtx.lineTo(x,y);else metricsCtx.moveTo(x,y);}); metricsCtx.stroke(); | |
| data.forEach((snapshot,index)=>{const x=pointX(index),y=getY(snapshot.metrics),active=index===state.viewIndex;metricsCtx.beginPath();metricsCtx.arc(x,y,active?5:2.5,0,Math.PI*2);metricsCtx.fillStyle=active?"white":color;metricsCtx.fill();metricsCtx.strokeStyle=color;metricsCtx.lineWidth=active?2.5:1;metricsCtx.stroke();}); | |
| }; | |
| drawSeries("#8b5cf6", metrics => lossY(metrics.loss)); | |
| drawSeries("#159267", metrics => accuracyY(metrics.accuracy)); | |
| } | |
| function dimensions() { | |
| const rect = canvas.getBoundingClientRect(), ratio = window.devicePixelRatio || 1; | |
| if (canvas.width !== Math.round(rect.width * ratio) || canvas.height !== Math.round(rect.height * ratio)) { canvas.width = Math.round(rect.width * ratio); canvas.height = Math.round(rect.height * ratio); } | |
| ctx.setTransform(ratio, 0, 0, ratio, 0, 0); return { width: rect.width, height: rect.height, pad: 40 }; | |
| } | |
| function toPixel(x, y, d) { const sizeX = d.width - d.pad * 2, sizeY = d.height - d.pad * 2; return [d.pad + (x - RANGE.min) / 10 * sizeX, d.height - d.pad - (y - RANGE.min) / 10 * sizeY]; } | |
| function fromPixel(px, py, d) { return [RANGE.min + (px - d.pad) / (d.width - d.pad * 2) * 10, RANGE.min + (d.height - d.pad - py) / (d.height - d.pad * 2) * 10]; } | |
| function predictedClass(x, y, model) { | |
| if (!model) return null; | |
| const scoreA = model.coef[0][0] * x + model.coef[0][1] * y + model.intercept[0]; | |
| const scoreB = model.coef[1][0] * x + model.coef[1][1] * y + model.intercept[1]; | |
| return scoreA >= scoreB ? "A" : "B"; | |
| } | |
| function draw() { | |
| const snapshot = visibleSnapshot(), model = snapshot.model, step = snapshot.step; | |
| const d = dimensions(); ctx.clearRect(0, 0, d.width, d.height); | |
| const [left, bottom] = toPixel(RANGE.min, RANGE.min, d), [right, top] = toPixel(RANGE.max, RANGE.max, d); | |
| ctx.fillStyle = "#fbfcfa"; ctx.fillRect(left, top, right - left, bottom - top); | |
| if (model && step > 0) { const cells = 55, cellW = (right-left)/cells, cellH = (bottom-top)/cells; for (let row=0; row<cells; row++) for (let col=0; col<cells; col++) { const [x,y] = fromPixel(left+(col+.5)*cellW, top+(row+.5)*cellH,d); ctx.fillStyle=REGION[predictedClass(x,y,model)]; ctx.fillRect(left+col*cellW,top+row*cellH,cellW+1,cellH+1); } } | |
| ctx.strokeStyle = "#e3e8e4"; ctx.lineWidth = 1; ctx.fillStyle = "#7b8881"; ctx.font = "10px system-ui"; | |
| for (let value=-4; value<=4; value+=2) { const [x] = toPixel(value,0,d), [,y]=toPixel(0,value,d); ctx.beginPath(); ctx.moveTo(x,top);ctx.lineTo(x,bottom);ctx.moveTo(left,y);ctx.lineTo(right,y);ctx.stroke(); ctx.fillText(String(value),x-5,bottom+17); ctx.fillText(String(value),left-23,y+3); } | |
| const [axisX, axisY] = toPixel(0, 0, d); | |
| ctx.strokeStyle="#718078"; ctx.fillStyle="#526159"; ctx.lineWidth=1.4; | |
| ctx.beginPath(); ctx.moveTo(left,axisY); ctx.lineTo(right-1,axisY); ctx.moveTo(axisX,bottom); ctx.lineTo(axisX,top+1); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(right-1,axisY); ctx.lineTo(right-8,axisY-4); ctx.lineTo(right-8,axisY+4); ctx.closePath(); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(axisX,top+1); ctx.lineTo(axisX-4,top+8); ctx.lineTo(axisX+4,top+8); ctx.closePath(); ctx.fill(); | |
| ctx.font="italic 12px Georgia, serif"; ctx.fillText("x₁",right-13,axisY-9); ctx.fillText("x₂",axisX+9,top+14); | |
| ctx.strokeStyle="#aebbb3"; ctx.strokeRect(left,top,right-left,bottom-top); | |
| if (step === 0 && hasBothClasses()) { | |
| // Zero initialization has no unique separating hyperplane; use x = 0 as | |
| // the visible reference boundary until the first gradient update. | |
| const [x] = toPixel(0, 0, d); | |
| ctx.save(); ctx.setLineDash([6, 6]); ctx.strokeStyle="#68766e"; ctx.lineWidth=2; | |
| ctx.beginPath(); ctx.moveTo(x,top); ctx.lineTo(x,bottom); ctx.stroke(); ctx.restore(); | |
| } | |
| else if (model) { const w0=model.coef[0][0]-model.coef[1][0], w1=model.coef[0][1]-model.coef[1][1], b=model.intercept[0]-model.intercept[1]; if (Math.abs(w1)>1e-10) { const y1=-(w0*RANGE.min+b)/w1,y2=-(w0*RANGE.max+b)/w1,[px1,py1]=toPixel(RANGE.min,y1,d),[px2,py2]=toPixel(RANGE.max,y2,d); ctx.save();ctx.beginPath();ctx.rect(left,top,right-left,bottom-top);ctx.clip();ctx.strokeStyle="#26342c";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(px1,py1);ctx.lineTo(px2,py2);ctx.stroke();ctx.restore(); } else if(Math.abs(w0)>1e-10) { const x=-b/w0,[px]=toPixel(x,0,d);ctx.strokeStyle="#26342c";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(px,top);ctx.lineTo(px,bottom);ctx.stroke(); } } | |
| const selected = new Set(snapshot.nextBatch); | |
| state.points.forEach((point, index) => { | |
| const [x,y]=toPixel(point.x,point.y,d); | |
| if (selected.has(index)) { | |
| ctx.beginPath(); ctx.arc(x,y,11,0,Math.PI*2); | |
| ctx.fillStyle="rgba(255, 202, 58, .28)"; ctx.fill(); | |
| ctx.strokeStyle="#d29400"; ctx.lineWidth=2; ctx.stroke(); | |
| } | |
| ctx.beginPath();ctx.arc(x,y,6,0,Math.PI*2);ctx.fillStyle=COLORS[point.label];ctx.fill();ctx.strokeStyle="white";ctx.lineWidth=2;ctx.stroke(); | |
| }); | |
| if (state.hoveredPoint >= 0 && state.points[state.hoveredPoint]) { | |
| const point = state.points[state.hoveredPoint], [x,y] = toPixel(point.x,point.y,d); | |
| const text = `Class ${point.label} · (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; | |
| ctx.font = "11px system-ui"; const width = ctx.measureText(text).width + 16; | |
| const boxX = Math.min(Math.max(left + 4, x + 12), right - width - 4); | |
| const boxY = y - 35 < top ? y + 13 : y - 35; | |
| ctx.fillStyle = "rgba(31, 43, 36, .94)"; ctx.beginPath(); ctx.roundRect(boxX, boxY, width, 27, 5); ctx.fill(); | |
| ctx.fillStyle = "white"; ctx.fillText(text, boxX + 8, boxY + 18); | |
| } | |
| } | |
| canvas.addEventListener("mousemove", event => { | |
| const d=dimensions(), rect=canvas.getBoundingClientRect(), px=event.clientX-rect.left, py=event.clientY-rect.top; | |
| let nearest=-1, distance=10; | |
| state.points.forEach((point,index)=>{const [x,y]=toPixel(point.x,point.y,d), current=Math.hypot(px-x,py-y);if(current<distance){nearest=index;distance=current;}}); | |
| if (nearest !== state.hoveredPoint) { state.hoveredPoint=nearest; canvas.style.cursor=nearest>=0?"pointer":state.datasetMode==="custom"?"crosshair":"default"; draw(); } | |
| }); | |
| canvas.addEventListener("mouseleave",()=>{if(state.hoveredPoint>=0){state.hoveredPoint=-1;canvas.style.cursor=state.datasetMode==="custom"?"crosshair":"default";draw();}}); | |
| canvas.addEventListener("click", event => { if (state.busy || state.animating || state.datasetMode !== "custom") return; const d=dimensions(), rect=canvas.getBoundingClientRect(), px=event.clientX-rect.left,py=event.clientY-rect.top; if(px<d.pad||px>d.width-d.pad||py<d.pad||py>d.height-d.pad)return; const [x,y]=fromPixel(px,py,d);state.points.push({x,y,label:state.label});state.redoPoints=[];state.hoveredPoint=-1;resetModel("Dataset changed. The model was reset."); }); | |
| ui["label-a"].addEventListener("click",()=>{state.label="A";ui["label-a"].classList.add("active");ui["label-b"].classList.remove("active");}); | |
| ui["label-b"].addEventListener("click",()=>{state.label="B";ui["label-b"].classList.add("active");ui["label-a"].classList.remove("active");}); | |
| ui.undo.addEventListener("click",()=>{const point=state.points.pop();if(point)state.redoPoints.push(point);state.hoveredPoint=-1;resetModel("Dataset changed. The model was reset.");}); | |
| ui.redo.addEventListener("click",()=>{const point=state.redoPoints.pop();if(point)state.points.push(point);resetModel("Point restored. The model was reset.");}); | |
| ui.clear.addEventListener("click",()=>{state.points=[];state.redoPoints=[];state.hoveredPoint=-1;resetModel("Dataset cleared.");}); | |
| function loadDataset(mode, message) { | |
| ui["dataset-select"].value = mode; | |
| state.datasetMode = mode; | |
| ui["custom-data-controls"].hidden = state.datasetMode !== "custom"; | |
| state.points = state.datasetMode === "custom" ? [] : structuredClone(BUILTIN_DATASETS[state.datasetMode]); | |
| state.redoPoints = []; state.hoveredPoint = -1; | |
| canvas.style.cursor = state.datasetMode === "custom" ? "crosshair" : "default"; | |
| resetModel(message || (state.datasetMode === "custom" ? "Click the plot to create a custom dataset." : "Builtin dataset loaded. The model was reset.")); | |
| } | |
| ui["dataset-select"].addEventListener("change", () => loadDataset(ui["dataset-select"].value)); | |
| function selectTab(name) { | |
| for (const tab of ["configure", "train", "examples", "usage"]) { | |
| const active = tab === name; | |
| ui[`${tab}-tab`].classList.toggle("active", active); | |
| ui[`${tab}-panel`].classList.toggle("active", active); | |
| } | |
| if (name === "train") requestAnimationFrame(drawMetrics); | |
| } | |
| ui["configure-tab"].addEventListener("click", () => selectTab("configure")); | |
| ui["train-tab"].addEventListener("click", () => selectTab("train")); | |
| ui["examples-tab"].addEventListener("click", () => selectTab("examples")); | |
| ui["usage-tab"].addEventListener("click", () => selectTab("usage")); | |
| for (const button of document.querySelectorAll(".example-button")) { | |
| button.addEventListener("click", () => { | |
| pauseAnimation(); | |
| ui.loss.value = button.dataset.loss || "perceptron"; | |
| ui["learning-rate"].value = button.dataset.learningRate || "1"; | |
| ui["batch-size"].value = "1"; | |
| ui["batch-selection"].value = "random"; ui["random-seed"].value = "42"; ui["animation-speed"].value = "200"; | |
| ui["sampling-help"].textContent = "Each point is used once before the dataset is shuffled again."; | |
| loadDataset(button.dataset.dataset, `${button.textContent.trim()} example loaded.`); | |
| selectTab("train"); | |
| }); | |
| } | |
| ui["history-next"].addEventListener("click", trainOneStep); | |
| ui["history-prev"].addEventListener("click", () => { if (state.viewIndex > 0) { state.viewIndex -= 1; renderView(); } }); | |
| ui["history-reset"].addEventListener("click", () => { if (state.history.length) { state.viewIndex = 0; renderView(); } }); | |
| ui.animate.addEventListener("click", startAnimation); ui.pause.addEventListener("click", pauseAnimation); | |
| for (const id of ["loss","learning-rate","batch-size","random-seed","batch-selection"]) ui[id].addEventListener("change",()=>{if(id==="batch-selection")ui["sampling-help"].textContent=ui[id].value==="random"?"Each step independently samples a subset without replacement.":"Each point is used once before the dataset is shuffled again.";resetModel("Configuration changed. The model was reset.");}); | |
| window.addEventListener("keydown",event=>{if(event.code==="Space"&&!event.repeat&&!/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)){event.preventDefault();trainOneStep();}}); | |
| new ResizeObserver(draw).observe(canvas.parentElement); new ResizeObserver(drawMetrics).observe(metricsCanvas); queueNextBatch(); updateControls(); draw(); initializeRuntime(); | |