schoolofstatistics / src /js /common.js
berangerthomas's picture
Add Fourier & linear pages; unify styles
09edf62
// ============================================================
// common.js — Shared utilities for School of Statistics
// ============================================================
// --- METRIC EXPLANATIONS (used in tooltip callbacks) ---
const metricExplanations = {
'AUC': {
description: "Probability that a randomly chosen positive instance is ranked higher than a randomly chosen negative instance.",
range: "0 to 1. 0.5 corresponds to random chance.",
formula: "Area Under the ROC Curve"
},
'Accuracy': {
description: "Proportion of all predictions that are correct.",
range: "0 to 1.",
formula: "(TP + TN) / (TP + TN + FP + FN)"
},
'Precision': {
description: "Proportion of positive predictions that are correct. A high value indicates a low false positive rate.",
range: "0 to 1.",
formula: "TP / (TP + FP)"
},
'Recall': {
description: "Proportion of actual positives correctly identified. Also called Sensitivity or True Positive Rate.",
range: "0 to 1.",
formula: "TP / (TP + FN)"
},
'Specificity': {
description: "Proportion of actual negatives correctly identified. Also called True Negative Rate.",
range: "0 to 1.",
formula: "TN / (TN + FP)"
},
'F1-Score': {
description: "Harmonic mean of Precision and Recall. Balances both metrics in a single value.",
range: "0 to 1.",
formula: "2 * (Precision * Recall) / (Precision + Recall)"
}
};
// --- RANDOM GAUSSIAN (Box-Muller transform) ---
function randomGaussian(mean = 0, stdDev = 1) {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return mean + stdDev * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
// --- ROC CURVE & AUC CALCULATION ---
function calculateRocAndAuc(labels, scores) {
const pairs = labels.map((label, i) => ({ label, score: scores[i] }));
pairs.sort((a, b) => b.score - a.score);
let tp = 0, fp = 0;
const total_pos = labels.filter(l => l === 1).length;
const total_neg = labels.length - total_pos;
if (total_pos === 0 || total_neg === 0) {
return { rocPoints: [{ x: 0, y: 0 }, { x: 1, y: 1 }], auc: 0.5 };
}
const rocPoints = [{ x: 0, y: 0 }];
let auc = 0, prev_tpr = 0, prev_fpr = 0;
for (const pair of pairs) {
if (pair.label === 1) tp++; else fp++;
const tpr = total_pos > 0 ? tp / total_pos : 0;
const fpr = total_neg > 0 ? fp / total_neg : 0;
auc += (tpr + prev_tpr) / 2 * (fpr - prev_fpr);
rocPoints.push({ x: fpr, y: tpr });
prev_tpr = tpr;
prev_fpr = fpr;
}
return { rocPoints, auc };
}
// --- CONFUSION MATRIX DRAWING (Canvas 2D) ---
function drawConfusionMatrix(canvasId, tp, fp, tn, fn) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const margin = 50;
const gridW = w - margin, gridH = h - margin;
const cellW = gridW / 2, cellH = gridH / 2;
const max_val = Math.max(tp, fp, tn, fn);
const baseColor = [8, 48, 107];
const cells = [
{ label: 'TN', value: tn, x: 0, y: cellH },
{ label: 'FP', value: fp, x: cellW, y: cellH },
{ label: 'FN', value: fn, x: 0, y: 0 },
{ label: 'TP', value: tp, x: cellW, y: 0 }
];
cells.forEach(cell => {
const intensity = max_val > 0 ? cell.value / max_val : 0;
ctx.fillStyle = `rgba(${baseColor[0]}, ${baseColor[1]}, ${baseColor[2]}, ${intensity})`;
ctx.fillRect(margin + cell.x, cell.y, cellW, cellH);
ctx.fillStyle = intensity > 0.5 ? 'white' : 'black';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 20px Segoe UI';
ctx.fillText(cell.label, margin + cell.x + cellW / 2, cell.y + cellH / 2 - 12);
ctx.font = '18px Segoe UI';
ctx.fillText(cell.value, margin + cell.x + cellW / 2, cell.y + cellH / 2 + 12);
});
ctx.fillStyle = '#333';
ctx.font = 'bold 14px Segoe UI';
ctx.fillText('Negative', margin + cellW / 2, gridH + 20);
ctx.fillText('Positive', margin + cellW + cellW / 2, gridH + 20);
ctx.save();
ctx.translate(20, gridH / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Positive', -cellH / 2, 0);
ctx.fillText('Negative', cellH / 2, 0);
ctx.restore();
}
// --- CUSTOM DATALABELS PLUGIN (for bar charts) ---
const customDatalabelsPlugin = {
id: 'customDatalabels',
afterDatasetsDraw: (chart) => {
const ctx = chart.ctx;
ctx.save();
ctx.font = 'bold 12px Segoe UI';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
chart.data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i);
meta.data.forEach((bar, index) => {
const data = dataset.data[index];
if (bar.height > 15) {
ctx.textBaseline = 'bottom';
ctx.fillText(data.toFixed(3), bar.x, bar.y + bar.height - 5);
}
});
});
ctx.restore();
}
};
// --- DEBOUNCE UTILITY ---
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// --- DRAGGABLE CONTROLS PANEL ---
function makeDraggable(element, handle) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
let isDragging = false;
handle.onmousedown = (e) => {
// Don't drag if clicking on a form control (slider, input, etc.)
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' ||
e.target.tagName === 'TEXTAREA' || e.target.classList.contains('slider')) {
return;
}
isDragging = true;
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
};
const elementDrag = (e) => {
if (!isDragging) return;
e.preventDefault();
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
element.style.top = (element.offsetTop - pos2) + "px";
element.style.left = (element.offsetLeft - pos1) + "px";
};
const closeDragElement = () => {
isDragging = false;
document.onmouseup = null;
document.onmousemove = null;
};
}
// --- METRICS BAR CHART TOOLTIP CALLBACK ---
function metricsTooltipCallback(context) {
const label = context.chart.data.labels[context.dataIndex];
const value = context.raw.toFixed(3);
const explanation = metricExplanations[label];
let tooltipText = [`${label}: ${value}`];
if (explanation) {
tooltipText.push('');
const roleLines = `${explanation.description}`.match(/.{1,50}(\s|$)/g) || [];
roleLines.forEach(line => tooltipText.push(line.trim()));
tooltipText.push(`Range: ${explanation.range}`);
tooltipText.push(`Formula: ${explanation.formula}`);
}
return tooltipText;
}