const canvas = document.getElementById("plot");
const ctx = canvas.getContext("2d");
const controls = {
b0: document.getElementById("b0"),
b1: document.getElementById("b1"),
t: document.getElementById("t"),
probeX: document.getElementById("probeX"),
showGrid: document.getElementById("showGrid"),
showShade: document.getElementById("showShade"),
showDerivative: document.getElementById("showDerivative"),
preset: document.getElementById("preset"),
resetBtn: document.getElementById("resetBtn"),
animateBtn: document.getElementById("animateBtn"),
};
const valueLabels = {
b0: document.getElementById("b0Value"),
b1: document.getElementById("b1Value"),
t: document.getElementById("tValue"),
probeX: document.getElementById("probeValue"),
};
const stats = document.getElementById("stats");
const points = [];
let animationTimer = null;
let animationDirection = 1;
const MARGIN = { left: 70, right: 24, top: 24, bottom: 58 };
const X_MIN = -10;
const X_MAX = 10;
function logistic(z) {
return 1 / (1 + Math.exp(-z));
}
function modelProbability(x, b0, b1) {
return logistic(b0 + b1 * x);
}
function xToCanvas(x) {
const w = canvas.width - MARGIN.left - MARGIN.right;
return MARGIN.left + ((x - X_MIN) / (X_MAX - X_MIN)) * w;
}
function yToCanvas(y) {
const h = canvas.height - MARGIN.top - MARGIN.bottom;
return canvas.height - MARGIN.bottom - y * h;
}
function canvasToX(pixelX) {
const w = canvas.width - MARGIN.left - MARGIN.right;
return X_MIN + ((pixelX - MARGIN.left) / w) * (X_MAX - X_MIN);
}
function format(num, digits = 3) {
return Number(num).toFixed(digits);
}
function drawAxes(showGrid) {
const plotLeft = MARGIN.left;
const plotRight = canvas.width - MARGIN.right;
const plotTop = MARGIN.top;
const plotBottom = canvas.height - MARGIN.bottom;
ctx.strokeStyle = "#c9d9df";
ctx.lineWidth = 1;
if (showGrid) {
for (let x = -10; x <= 10; x += 2) {
const px = xToCanvas(x);
ctx.beginPath();
ctx.moveTo(px, plotTop);
ctx.lineTo(px, plotBottom);
ctx.stroke();
}
for (let y = 0; y <= 1.001; y += 0.1) {
const py = yToCanvas(y);
ctx.beginPath();
ctx.moveTo(plotLeft, py);
ctx.lineTo(plotRight, py);
ctx.stroke();
}
}
ctx.strokeStyle = "#14343f";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(plotLeft, yToCanvas(0));
ctx.lineTo(plotRight, yToCanvas(0));
ctx.stroke();
ctx.beginPath();
ctx.moveTo(xToCanvas(0), plotTop);
ctx.lineTo(xToCanvas(0), plotBottom);
ctx.stroke();
ctx.fillStyle = "#14343f";
ctx.font = "13px 'Avenir Next', sans-serif";
for (let x = -10; x <= 10; x += 2) {
const px = xToCanvas(x);
ctx.fillText(String(x), px - 8, yToCanvas(0) + 19);
}
for (let y = 0; y <= 1.001; y += 0.2) {
const py = yToCanvas(y);
ctx.fillText(format(y, 1), plotLeft - 40, py + 4);
}
ctx.fillText("x", plotRight - 12, yToCanvas(0) + 40);
ctx.fillText("p", xToCanvas(0) + 12, plotTop + 12);
}
function drawThresholdLine(t) {
ctx.strokeStyle = "#d8534f";
ctx.lineWidth = 1.3;
ctx.setLineDash([7, 5]);
ctx.beginPath();
ctx.moveTo(MARGIN.left, yToCanvas(t));
ctx.lineTo(canvas.width - MARGIN.right, yToCanvas(t));
ctx.stroke();
ctx.setLineDash([]);
}
function drawRegionShade(b0, b1, t) {
if (Math.abs(b1) < 1e-9) {
return;
}
const logit = Math.log(t / (1 - t));
const boundaryX = (logit - b0) / b1;
const left = MARGIN.left;
const right = canvas.width - MARGIN.right;
const top = MARGIN.top;
const bottom = canvas.height - MARGIN.bottom;
const boundaryPx = xToCanvas(Math.max(X_MIN, Math.min(X_MAX, boundaryX)));
ctx.globalAlpha = 0.15;
ctx.fillStyle = "#0a8f7b";
if (b1 > 0) {
ctx.fillRect(boundaryPx, top, right - boundaryPx, bottom - top);
} else {
ctx.fillRect(left, top, boundaryPx - left, bottom - top);
}
ctx.globalAlpha = 1;
}
function drawCurve(b0, b1) {
ctx.strokeStyle = "#0a8f7b";
ctx.lineWidth = 3;
ctx.beginPath();
const steps = 600;
for (let i = 0; i <= steps; i += 1) {
const x = X_MIN + (i / steps) * (X_MAX - X_MIN);
const y = modelProbability(x, b0, b1);
const px = xToCanvas(x);
const py = yToCanvas(y);
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.stroke();
}
function drawDerivative(b0, b1) {
ctx.strokeStyle = "#f2b84b";
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
const steps = 600;
for (let i = 0; i <= steps; i += 1) {
const x = X_MIN + (i / steps) * (X_MAX - X_MIN);
const p = modelProbability(x, b0, b1);
const derivative = Math.abs(b1 * p * (1 - p));
const y = Math.min(1, derivative * 4);
const px = xToCanvas(x);
const py = yToCanvas(y);
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawProbeAndSamples(b0, b1, t, probeX) {
const probeP = modelProbability(probeX, b0, b1);
for (const sampleX of points) {
const p = modelProbability(sampleX, b0, b1);
const positive = p >= t;
ctx.fillStyle = positive ? "#0a8f7b" : "#d8534f";
ctx.beginPath();
ctx.arc(xToCanvas(sampleX), yToCanvas(p), 5, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "#14343f";
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(xToCanvas(probeX), yToCanvas(0));
ctx.lineTo(xToCanvas(probeX), yToCanvas(probeP));
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#14343f";
ctx.beginPath();
ctx.arc(xToCanvas(probeX), yToCanvas(probeP), 6, 0, Math.PI * 2);
ctx.fill();
ctx.font = "12px 'Avenir Next', sans-serif";
ctx.fillText(`probe p=${format(probeP)}`, xToCanvas(probeX) + 8, yToCanvas(probeP) - 8);
}
function renderStats(b0, b1, t, probeX) {
const probeP = modelProbability(probeX, b0, b1);
let boundaryText = "No finite boundary (b1≈0)";
if (Math.abs(b1) > 1e-9) {
const logit = Math.log(t / (1 - t));
const boundaryX = (logit - b0) / b1;
boundaryText = `Decision boundary x*: ${format(boundaryX)}`;
}
const positives = points.filter((x) => modelProbability(x, b0, b1) >= t).length;
const negatives = points.length - positives;
stats.innerHTML = `
Equation: p = 1/(1+exp(-(${format(b0)} + ${format(b1)}x)))
Threshold t = ${format(t, 2)}
Probe: x = ${format(probeX, 2)}, p = ${format(probeP)}
${boundaryText}
Sample points: ${points.length} total
Class counts by t: ${positives} positive, ${negatives} negative
`;
}
function updateLabelValues() {
valueLabels.b0.textContent = format(controls.b0.value, 2);
valueLabels.b1.textContent = format(controls.b1.value, 2);
valueLabels.t.textContent = format(controls.t.value, 2);
valueLabels.probeX.textContent = format(controls.probeX.value, 2);
}
function render() {
const b0 = Number(controls.b0.value);
const b1 = Number(controls.b1.value);
const t = Number(controls.t.value);
const probeX = Number(controls.probeX.value);
updateLabelValues();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawAxes(controls.showGrid.checked);
if (controls.showShade.checked) {
drawRegionShade(b0, b1, t);
}
drawThresholdLine(t);
drawCurve(b0, b1);
if (controls.showDerivative.checked) {
drawDerivative(b0, b1);
}
drawProbeAndSamples(b0, b1, t, probeX);
renderStats(b0, b1, t, probeX);
}
function reset() {
controls.b0.value = 0;
controls.b1.value = 1;
controls.t.value = 0.5;
controls.probeX.value = 0;
controls.showGrid.checked = true;
controls.showShade.checked = true;
controls.showDerivative.checked = false;
controls.preset.value = "default";
points.length = 0;
stopAnimation();
render();
}
function applyPreset(presetKey) {
const presetMap = {
default: { b0: 0, b1: 1, t: 0.5 },
steep: { b0: -1, b1: 3, t: 0.5 },
reversed: { b0: 0.5, b1: -2.2, t: 0.5 },
"high-threshold": { b0: 0, b1: 1, t: 0.8 },
};
const preset = presetMap[presetKey] || presetMap.default;
controls.b0.value = preset.b0;
controls.b1.value = preset.b1;
controls.t.value = preset.t;
render();
}
function startAnimation() {
if (animationTimer) return;
controls.animateBtn.textContent = "Stop Animation";
animationTimer = setInterval(() => {
const current = Number(controls.probeX.value);
let next = current + animationDirection * 0.18;
if (next > X_MAX) {
next = X_MAX;
animationDirection = -1;
}
if (next < X_MIN) {
next = X_MIN;
animationDirection = 1;
}
controls.probeX.value = next;
render();
}, 30);
}
function stopAnimation() {
if (animationTimer) {
clearInterval(animationTimer);
animationTimer = null;
}
controls.animateBtn.textContent = "Animate Probe";
}
for (const key of ["b0", "b1", "t", "probeX", "showGrid", "showShade", "showDerivative"]) {
controls[key].addEventListener("input", render);
}
controls.resetBtn.addEventListener("click", reset);
controls.preset.addEventListener("change", (event) => {
applyPreset(event.target.value);
});
controls.animateBtn.addEventListener("click", () => {
if (animationTimer) {
stopAnimation();
} else {
startAnimation();
}
});
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const x = canvasToX((clickX / rect.width) * canvas.width);
if (x < X_MIN || x > X_MAX) {
return;
}
points.push(Number(x.toFixed(2)));
if (points.length > 30) {
points.shift();
}
render();
});
reset();