| |
| |
| |
|
|
| |
| let mainChart, residualChart; |
| let points = []; |
| let nextPointId = 0; |
| let isDragging = false; |
| let draggedPointId = null; |
| let regressionCoefficients = null; |
|
|
| |
| const X_MIN = -10; |
| const X_MAX = 10; |
| const Y_MIN = -10; |
| const Y_MAX = 10; |
|
|
| |
| let currentXMin = X_MIN; |
| let currentXMax = X_MAX; |
| let currentYMin = Y_MIN; |
| let currentYMax = Y_MAX; |
| const ZOOM_FACTOR = 0.1; |
|
|
| |
|
|
| |
| |
| |
| |
| |
| function buildVandermonde(X, degree) { |
| const n = X.length; |
| const V = []; |
| for (let i = 0; i < n; i++) { |
| const row = []; |
| for (let j = 0; j <= degree; j++) { |
| row.push(Math.pow(X[i], j)); |
| } |
| V.push(row); |
| } |
| return V; |
| } |
|
|
| |
| |
| |
| function transpose(matrix) { |
| const rows = matrix.length; |
| const cols = matrix[0].length; |
| const result = []; |
| for (let j = 0; j < cols; j++) { |
| const row = []; |
| for (let i = 0; i < rows; i++) { |
| row.push(matrix[i][j]); |
| } |
| result.push(row); |
| } |
| return result; |
| } |
|
|
| |
| |
| |
| function multiply(A, B) { |
| const rowsA = A.length; |
| const colsA = A[0].length; |
| const colsB = B[0].length; |
| const result = []; |
| for (let i = 0; i < rowsA; i++) { |
| const row = []; |
| for (let j = 0; j < colsB; j++) { |
| let sum = 0; |
| for (let k = 0; k < colsA; k++) { |
| sum += A[i][k] * B[k][j]; |
| } |
| row.push(sum); |
| } |
| result.push(row); |
| } |
| return result; |
| } |
|
|
| |
| |
| |
| function solveLinearSystem(A, b) { |
| const n = A.length; |
| |
| const M = A.map((row, i) => [...row, b[i][0]]); |
| |
| |
| for (let i = 0; i < n; i++) { |
| |
| let maxRow = i; |
| for (let k = i + 1; k < n; k++) { |
| if (Math.abs(M[k][i]) > Math.abs(M[maxRow][i])) { |
| maxRow = k; |
| } |
| } |
| |
| [M[i], M[maxRow]] = [M[maxRow], M[i]]; |
| |
| |
| for (let k = i + 1; k < n; k++) { |
| const factor = M[k][i] / M[i][i]; |
| for (let j = i; j <= n; j++) { |
| M[k][j] -= factor * M[i][j]; |
| } |
| } |
| } |
| |
| |
| const x = new Array(n).fill(0); |
| for (let i = n - 1; i >= 0; i--) { |
| x[i] = M[i][n] / M[i][i]; |
| for (let k = i - 1; k >= 0; k--) { |
| M[k][n] -= M[k][i] * x[i]; |
| } |
| } |
| |
| return x; |
| } |
|
|
| |
| |
| |
| |
| function fitPolynomialRegression(X, y, degree) { |
| if (X.length < degree + 1) { |
| return null; |
| } |
| |
| const V = buildVandermonde(X, degree); |
| const Vt = transpose(V); |
| const VtV = multiply(Vt, V); |
| const Vty = multiply(Vt, y.map(val => [val])); |
| |
| |
| const coefficients = solveLinearSystem(VtV, Vty); |
| return coefficients; |
| } |
|
|
| |
| |
| |
| function predict(X, coefficients) { |
| if (!coefficients) return X.map(() => 0); |
| return X.map(x => { |
| let y = 0; |
| for (let i = 0; i < coefficients.length; i++) { |
| y += coefficients[i] * Math.pow(x, i); |
| } |
| return y; |
| }); |
| } |
|
|
| |
| |
| |
| function calculateStatistics(yTrue, yPred, n, degree) { |
| const residuals = yTrue.map((y, i) => y - yPred[i]); |
| const ssRes = residuals.reduce((sum, r) => sum + r * r, 0); |
| const yMean = yTrue.reduce((sum, y) => sum + y, 0) / yTrue.length; |
| const ssTot = yTrue.reduce((sum, y) => sum + Math.pow(y - yMean, 2), 0); |
| |
| const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0; |
| const adjustedR2 = n > degree + 1 ? 1 - (1 - r2) * (n - 1) / (n - degree - 1) : r2; |
| const mse = ssRes / n; |
| const rmse = Math.sqrt(mse); |
| const mae = residuals.reduce((sum, r) => sum + Math.abs(r), 0) / n; |
| |
| return { r2, adjustedR2, mse, rmse, mae, residuals }; |
| } |
|
|
| |
| |
| |
| function calculateConfidenceInterval(X, coefficients, residuals, confidence = 0.95) { |
| if (!coefficients || X.length === 0) return X.map(() => ({ lower: 0, upper: 0 })); |
| |
| const n = residuals.length; |
| const degree = coefficients.length - 1; |
| |
| |
| const ssRes = residuals.reduce((sum, r) => sum + r * r, 0); |
| const sigma = Math.sqrt(ssRes / Math.max(1, n - degree - 1)); |
| |
| |
| const multiplier = 2; |
| |
| return X.map(x => { |
| const yPred = predict([x], coefficients)[0]; |
| return { |
| lower: yPred - multiplier * sigma, |
| upper: yPred + multiplier * sigma |
| }; |
| }); |
| } |
|
|
| |
|
|
| function initCharts() { |
| |
| const mainCtx = document.getElementById('mainChart').getContext('2d'); |
| mainChart = new Chart(mainCtx, { |
| type: 'scatter', |
| data: { |
| datasets: [ |
| { |
| label: 'Data Points', |
| data: [], |
| backgroundColor: '#1976d2', |
| borderColor: '#1976d2', |
| borderWidth: 2, |
| pointRadius: 6, |
| pointHoverRadius: 8, |
| pointHoverBorderWidth: 3 |
| }, |
| { |
| label: 'Regression Line', |
| data: [], |
| type: 'line', |
| borderColor: '#d32f2f', |
| backgroundColor: 'transparent', |
| borderWidth: 2, |
| pointRadius: 0, |
| fill: false, |
| tension: 0.4 |
| }, |
| { |
| label: 'Confidence Band Lower', |
| data: [], |
| type: 'line', |
| borderColor: 'transparent', |
| backgroundColor: 'rgba(211, 47, 47, 0.1)', |
| borderWidth: 0, |
| pointRadius: 0, |
| fill: '+1' |
| }, |
| { |
| label: 'Confidence Band Upper', |
| data: [], |
| type: 'line', |
| borderColor: 'transparent', |
| backgroundColor: 'rgba(211, 47, 47, 0.1)', |
| borderWidth: 0, |
| pointRadius: 0, |
| fill: false |
| }, |
| { |
| label: 'Residuals', |
| data: [], |
| type: 'line', |
| borderColor: '#757575', |
| backgroundColor: 'transparent', |
| borderWidth: 1, |
| borderDash: [3, 3], |
| pointRadius: 0, |
| showLine: true |
| } |
| ] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| animation: { duration: 0 }, |
| scales: { |
| x: { |
| min: X_MIN, |
| max: X_MAX, |
| title: { display: true, text: 'X' } |
| }, |
| y: { |
| min: Y_MIN, |
| max: Y_MAX, |
| title: { display: true, text: 'Y' } |
| } |
| }, |
| plugins: { |
| legend: { |
| display: true, |
| labels: { |
| filter: function(item) { |
| |
| return item.text !== 'Confidence Band Lower' && item.text !== 'Confidence Band Upper'; |
| } |
| } |
| }, |
| tooltip: { |
| callbacks: { |
| label: function(context) { |
| if (context.dataset.label === 'Data Points') { |
| return `Point (${context.parsed.x.toFixed(2)}, ${context.parsed.y.toFixed(2)})`; |
| } |
| return `${context.dataset.label}: ${context.parsed.y.toFixed(3)}`; |
| } |
| } |
| } |
| } |
| |
| } |
| }); |
|
|
| |
| const residualCtx = document.getElementById('residualChart').getContext('2d'); |
| residualChart = new Chart(residualCtx, { |
| type: 'scatter', |
| data: { |
| datasets: [ |
| { |
| label: 'Residuals', |
| data: [], |
| backgroundColor: '#1976d2', |
| pointRadius: 5 |
| }, |
| { |
| label: 'Zero Line', |
| data: [{ x: Y_MIN, y: 0 }, { x: Y_MAX, y: 0 }], |
| type: 'line', |
| borderColor: '#d32f2f', |
| borderWidth: 2, |
| pointRadius: 0, |
| borderDash: [5, 5] |
| } |
| ] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| animation: { duration: 0 }, |
| scales: { |
| x: { |
| title: { display: true, text: 'Predicted Values' } |
| }, |
| y: { |
| title: { display: true, text: 'Residuals' } |
| } |
| }, |
| plugins: { |
| legend: { display: false } |
| } |
| } |
| }); |
| } |
|
|
| |
|
|
| function findNearestPoint(x, y, threshold = 1.0) { |
| for (const point of points) { |
| const dist = Math.sqrt(Math.pow(point.x - x, 2) + Math.pow(point.y - y, 2)); |
| if (dist < threshold) { |
| return point; |
| } |
| } |
| return null; |
| } |
|
|
| function getMousePos(canvas, event) { |
| const rect = canvas.getBoundingClientRect(); |
| return { |
| x: event.clientX - rect.left, |
| y: event.clientY - rect.top |
| }; |
| } |
|
|
| function getChartCoordinates(canvas, event) { |
| const pos = getMousePos(canvas, event); |
| return { |
| x: mainChart.scales.x.getValueForPixel(pos.x), |
| y: mainChart.scales.y.getValueForPixel(pos.y) |
| }; |
| } |
|
|
| function addPoint(x, y) { |
| |
| x = Math.max(currentXMin, Math.min(currentXMax, x)); |
| y = Math.max(currentYMin, Math.min(currentYMax, y)); |
| |
| points.push({ x, y, id: nextPointId++ }); |
| updateRegression(); |
| } |
|
|
| function removePoint(id) { |
| points = points.filter(p => p.id !== id); |
| updateRegression(); |
| } |
|
|
| function updatePointPosition(id, x, y) { |
| const point = points.find(p => p.id === id); |
| if (point) { |
| point.x = Math.max(currentXMin, Math.min(currentXMax, x)); |
| point.y = Math.max(currentYMin, Math.min(currentYMax, y)); |
| updateRegression(); |
| } |
| } |
|
|
| |
|
|
| function zoom(factor, centerX, centerY) { |
| const xRange = currentXMax - currentXMin; |
| const yRange = currentYMax - currentYMin; |
| |
| |
| const newXRange = xRange * factor; |
| const newYRange = yRange * factor; |
| |
| |
| const xRatio = (centerX - currentXMin) / xRange; |
| const yRatio = (centerY - currentYMin) / yRange; |
| |
| |
| currentXMin = centerX - newXRange * xRatio; |
| currentXMax = centerX + newXRange * (1 - xRatio); |
| currentYMin = centerY - newYRange * yRatio; |
| currentYMax = centerY + newYRange * (1 - yRatio); |
| |
| |
| mainChart.options.scales.x.min = currentXMin; |
| mainChart.options.scales.x.max = currentXMax; |
| mainChart.options.scales.y.min = currentYMin; |
| mainChart.options.scales.y.max = currentYMax; |
| mainChart.update('none'); |
| } |
|
|
| function resetZoom() { |
| if (points.length === 0) { |
| |
| currentXMin = X_MIN; |
| currentXMax = X_MAX; |
| currentYMin = Y_MIN; |
| currentYMax = Y_MAX; |
| } else { |
| |
| const xs = points.map(p => p.x); |
| const ys = points.map(p => p.y); |
| |
| const xMin = Math.min(...xs); |
| const xMax = Math.max(...xs); |
| const yMin = Math.min(...ys); |
| const yMax = Math.max(...ys); |
| |
| |
| const xRange = xMax - xMin; |
| const yRange = yMax - yMin; |
| const xPadding = Math.max(xRange * 0.1, 1); |
| const yPadding = Math.max(yRange * 0.1, 1); |
| |
| currentXMin = xMin - xPadding; |
| currentXMax = xMax + xPadding; |
| currentYMin = yMin - yPadding; |
| currentYMax = yMax + yPadding; |
| } |
| |
| mainChart.options.scales.x.min = currentXMin; |
| mainChart.options.scales.x.max = currentXMax; |
| mainChart.options.scales.y.min = currentYMin; |
| mainChart.options.scales.y.max = currentYMax; |
| mainChart.update('none'); |
| } |
|
|
| |
|
|
| function updateRegression() { |
| const degree = parseInt(document.getElementById('degreeSlider').value); |
| const showResiduals = document.getElementById('showResiduals').checked; |
| const showConfidence = document.getElementById('showConfidence').checked; |
| |
| |
| mainChart.data.datasets[0].data = points.map(p => ({ x: p.x, y: p.y })); |
| |
| if (points.length < degree + 1) { |
| |
| mainChart.data.datasets[1].data = []; |
| mainChart.data.datasets[2].data = []; |
| mainChart.data.datasets[3].data = []; |
| mainChart.data.datasets[4].data = []; |
| residualChart.data.datasets[0].data = []; |
| updateStatisticsDisplay({ r2: 0, adjustedR2: 0, mse: 0, rmse: 0, mae: 0 }, points.length); |
| mainChart.update('none'); |
| residualChart.update('none'); |
| return; |
| } |
| |
| |
| const X = points.map(p => p.x); |
| const y = points.map(p => p.y); |
| regressionCoefficients = fitPolynomialRegression(X, y, degree); |
| |
| |
| const xCurve = []; |
| for (let x = currentXMin; x <= currentXMax; x += 0.2) { |
| xCurve.push(x); |
| } |
| const yCurve = predict(xCurve, regressionCoefficients); |
| |
| |
| mainChart.data.datasets[1].data = xCurve.map((x, i) => ({ x, y: yCurve[i] })); |
| |
| |
| const yPred = predict(X, regressionCoefficients); |
| const stats = calculateStatistics(y, yPred, points.length, degree); |
| |
| |
| if (showConfidence) { |
| const confidenceIntervals = calculateConfidenceInterval(xCurve, regressionCoefficients, stats.residuals); |
| mainChart.data.datasets[2].data = xCurve.map((x, i) => ({ x, y: confidenceIntervals[i].lower })); |
| mainChart.data.datasets[3].data = xCurve.map((x, i) => ({ x, y: confidenceIntervals[i].upper })); |
| } else { |
| mainChart.data.datasets[2].data = []; |
| mainChart.data.datasets[3].data = []; |
| } |
| |
| |
| if (showResiduals) { |
| const residualLines = []; |
| for (let i = 0; i < points.length; i++) { |
| residualLines.push({ x: X[i], y: y[i] }); |
| residualLines.push({ x: X[i], y: yPred[i] }); |
| residualLines.push({ x: NaN, y: NaN }); |
| } |
| mainChart.data.datasets[4].data = residualLines; |
| } else { |
| mainChart.data.datasets[4].data = []; |
| } |
| |
| |
| residualChart.data.datasets[0].data = yPred.map((yp, i) => ({ x: yp, y: stats.residuals[i] })); |
| |
| |
| updateStatisticsDisplay(stats, points.length); |
| |
| mainChart.update('none'); |
| residualChart.update('none'); |
| } |
|
|
| function updateStatisticsDisplay(stats, n) { |
| document.getElementById('r2Value').textContent = n > 0 ? stats.r2.toFixed(4) : '-'; |
| document.getElementById('adjustedR2Value').textContent = n > 0 ? stats.adjustedR2.toFixed(4) : '-'; |
| document.getElementById('mseValue').textContent = n > 0 ? stats.mse.toFixed(4) : '-'; |
| document.getElementById('maeValue').textContent = n > 0 ? stats.mae.toFixed(4) : '-'; |
| document.getElementById('rmseValue').textContent = n > 0 ? stats.rmse.toFixed(4) : '-'; |
| document.getElementById('nPointsValue').textContent = n; |
| } |
|
|
| |
|
|
| function setupEventListeners() { |
| |
| const degreeSlider = document.getElementById('degreeSlider'); |
| degreeSlider.addEventListener('input', function() { |
| document.getElementById('degreeValue').textContent = this.value; |
| updateRegression(); |
| }); |
| |
| |
| document.getElementById('showResiduals').addEventListener('change', updateRegression); |
| |
| |
| document.getElementById('showConfidence').addEventListener('change', updateRegression); |
| |
| |
| document.getElementById('clearPointsBtn').addEventListener('click', function() { |
| points = []; |
| updateRegression(); |
| }); |
| |
| |
| document.getElementById('addRandomBtn').addEventListener('click', function() { |
| for (let i = 0; i < 5; i++) { |
| const x = currentXMin + Math.random() * (currentXMax - currentXMin); |
| const y = (currentYMin + Math.random() * (currentYMax - currentYMin)) + randomGaussian(0, 2); |
| addPoint(x, y); |
| } |
| }); |
| |
| |
| document.getElementById('resetZoomBtn').addEventListener('click', resetZoom); |
| |
| |
| const canvas = document.getElementById('mainChart'); |
| let hasDragged = false; |
| let clickedOnPoint = false; |
| |
| |
| canvas.addEventListener('wheel', function(event) { |
| event.preventDefault(); |
| |
| const rect = canvas.getBoundingClientRect(); |
| const mouseX = event.clientX - rect.left; |
| const mouseY = event.clientY - rect.top; |
| |
| |
| const centerX = mainChart.scales.x.getValueForPixel(mouseX); |
| const centerY = mainChart.scales.y.getValueForPixel(mouseY); |
| |
| |
| const zoomDirection = event.deltaY < 0 ? (1 - ZOOM_FACTOR) : (1 + ZOOM_FACTOR); |
| zoom(zoomDirection, centerX, centerY); |
| }, { passive: false }); |
| |
| |
| canvas.addEventListener('mousedown', function(event) { |
| if (event.button !== 0) return; |
| |
| event.preventDefault(); |
| event.stopPropagation(); |
| |
| const coords = getChartCoordinates(canvas, event); |
| const nearestPoint = findNearestPoint(coords.x, coords.y); |
| |
| hasDragged = false; |
| |
| if (nearestPoint) { |
| |
| isDragging = true; |
| draggedPointId = nearestPoint.id; |
| clickedOnPoint = true; |
| canvas.classList.add('dragging'); |
| } else { |
| |
| clickedOnPoint = false; |
| } |
| }); |
| |
| |
| document.addEventListener('mousemove', function(event) { |
| |
| if (!isDragging) { |
| const coords = getChartCoordinates(canvas, event); |
| if (coords.x >= X_MIN && coords.x <= X_MAX && coords.y >= Y_MIN && coords.y <= Y_MAX) { |
| const nearestPoint = findNearestPoint(coords.x, coords.y); |
| if (nearestPoint) { |
| canvas.classList.add('hover-point'); |
| } else { |
| canvas.classList.remove('hover-point'); |
| } |
| } |
| return; |
| } |
| |
| |
| hasDragged = true; |
| |
| const rect = canvas.getBoundingClientRect(); |
| let canvasX = event.clientX - rect.left; |
| let canvasY = event.clientY - rect.top; |
| |
| |
| canvasX = Math.max(0, Math.min(canvasX, rect.width)); |
| canvasY = Math.max(0, Math.min(canvasY, rect.height)); |
| |
| const x = mainChart.scales.x.getValueForPixel(canvasX); |
| const y = mainChart.scales.y.getValueForPixel(canvasY); |
| |
| updatePointPosition(draggedPointId, x, y); |
| }); |
| |
| |
| document.addEventListener('mouseup', function(event) { |
| if (event.button !== 0) return; |
| |
| if (isDragging) { |
| |
| isDragging = false; |
| draggedPointId = null; |
| canvas.classList.remove('dragging'); |
| } else if (!clickedOnPoint) { |
| |
| const coords = getChartCoordinates(canvas, event); |
| |
| if (coords.x >= currentXMin && coords.x <= currentXMax && coords.y >= currentYMin && coords.y <= currentYMax) { |
| addPoint(coords.x, coords.y); |
| } |
| } |
| |
| |
| hasDragged = false; |
| clickedOnPoint = false; |
| }); |
| |
| |
| canvas.addEventListener('contextmenu', function(event) { |
| event.preventDefault(); |
| event.stopPropagation(); |
| |
| if (isDragging) { |
| |
| isDragging = false; |
| draggedPointId = null; |
| canvas.classList.remove('dragging'); |
| return; |
| } |
| |
| const coords = getChartCoordinates(canvas, event); |
| const clickedPoint = findNearestPoint(coords.x, coords.y); |
| if (clickedPoint) { |
| removePoint(clickedPoint.id); |
| } |
| }); |
| |
| |
| canvas.addEventListener('contextmenu', function(event) { |
| event.preventDefault(); |
| }); |
| } |
|
|
| |
|
|
| function main() { |
| initCharts(); |
| setupEventListeners(); |
| |
| |
| if (window.innerWidth > 1200) { |
| makeDraggable(document.getElementById('floatingControls'), document.getElementById('controlsTitle')); |
| } |
| |
| |
| for (let i = 0; i < 8; i++) { |
| const x = X_MIN + Math.random() * (X_MAX - X_MIN); |
| const y = 2 + 0.5 * x + randomGaussian(0, 1.5); |
| points.push({ x, y, id: nextPointId++ }); |
| } |
| |
| updateRegression(); |
| } |
|
|
| window.addEventListener('load', main); |
|
|