/**
* @fileoverview NNTrainingCharts — Canvas-basierte Mini-Charts für Loss & Accuracy.
*
* Rendert zwei kompakte Linien-Charts im Metriken-Tab des NN-Playgrounds:
* 1. Loss-Kurve (Cross-Entropy) — blau, fallend ist gut
* 2. Accuracy-Kurve (% richtige Vorhersagen) — grün, steigend ist gut
*
* Convention: Reine Rendering-Klasse. Kein State, keine Game-Logik.
*
* @author Alexander Wolf
* @see ToDos/NN_PLAYGROUND_PLAN_v2.md §3E
*/
class NNTrainingCharts {
/**
* @param {Object} config
* @param {HTMLElement|string} config.container — Container element or ID
*/
constructor(config) {
const container = typeof config.container === 'string'
? document.getElementById(config.container)
: config.container;
if (!container) return;
/** @type {HTMLElement} */
this.container = container;
/** @type {HTMLCanvasElement} */
this.lossCanvas = null;
/** @type {HTMLCanvasElement} */
this.accCanvas = null;
this._createCanvases();
}
/**
* Creates the two canvases inside the container.
* @private
*/
_createCanvases() {
this.container.innerHTML = '';
// Loss chart
const lossSection = document.createElement('div');
lossSection.className = 'nn-chart-section';
lossSection.innerHTML = '<div class="nn-chart-label">📉 Loss (Cross-Entropy)</div>';
this.lossCanvas = document.createElement('canvas');
this.lossCanvas.className = 'nn-chart-canvas';
this.lossCanvas.width = 280;
this.lossCanvas.height = 100;
lossSection.appendChild(this.lossCanvas);
this.container.appendChild(lossSection);
// Accuracy chart
const accSection = document.createElement('div');
accSection.className = 'nn-chart-section';
accSection.innerHTML = '<div class="nn-chart-label">📈 Accuracy (%)</div>';
this.accCanvas = document.createElement('canvas');
this.accCanvas.className = 'nn-chart-canvas';
this.accCanvas.width = 280;
this.accCanvas.height = 100;
accSection.appendChild(this.accCanvas);
this.container.appendChild(accSection);
}
/**
* Updates both charts with current history data.
* @param {number[]} lossHistory — Array of loss values per epoch
* @param {number[]} accuracyHistory — Array of accuracy values (0–1)
*/
update(lossHistory, accuracyHistory) {
if (this.lossCanvas && lossHistory.length > 0) {
this._drawLineChart(this.lossCanvas, lossHistory, {
color: '#3498db',
fillColor: 'rgba(52, 152, 219, 0.08)',
yLabel: 'Loss',
autoScaleY: true,
minY: 0
});
}
if (this.accCanvas && accuracyHistory.length > 0) {
this._drawLineChart(this.accCanvas, accuracyHistory, {
color: '#2ecc71',
fillColor: 'rgba(46, 204, 113, 0.08)',
yLabel: 'Acc',
autoScaleY: false,
minY: 0,
maxY: 1
});
}
}
/**
* Draws a responsive line chart on a canvas.
* @param {HTMLCanvasElement} canvas
* @param {number[]} data
* @param {Object} opts — color, fillColor, yLabel, autoScaleY, minY, maxY
* @private
*/
_drawLineChart(canvas, data, opts) {
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const pad = { top: 8, right: 8, bottom: 16, left: 36 };
const plotW = w - pad.left - pad.right;
const plotH = h - pad.top - pad.bottom;
ctx.clearRect(0, 0, w, h);
if (data.length < 2) return;
// Downsample if too many points
let plotData = data;
const maxPoints = Math.floor(plotW);
if (data.length > maxPoints) {
const step = data.length / maxPoints;
plotData = [];
for (let i = 0; i < maxPoints; i++) {
plotData.push(data[Math.floor(i * step)]);
}
}
// Y range
let minY = opts.minY !== undefined ? opts.minY : Math.min(...plotData);
let maxY = opts.maxY !== undefined ? opts.maxY : Math.max(...plotData);
if (opts.autoScaleY) {
maxY = Math.max(...plotData);
// Add 10% padding
const yRange = maxY - minY;
if (yRange > 0) maxY += yRange * 0.1;
}
if (maxY === minY) maxY = minY + 1;
// Grid lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const gy = pad.top + (plotH * i) / 4;
ctx.beginPath();
ctx.moveTo(pad.left, gy);
ctx.lineTo(w - pad.right, gy);
ctx.stroke();
}
// Y-axis labels
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = '9px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(maxY.toFixed(2), pad.left - 4, pad.top);
ctx.fillText(((maxY + minY) / 2).toFixed(2), pad.left - 4, pad.top + plotH / 2);
ctx.fillText(minY.toFixed(2), pad.left - 4, pad.top + plotH);
// X-axis label
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(`${data.length} Epochen`, w / 2, h - 12);
// Line path
ctx.beginPath();
for (let i = 0; i < plotData.length; i++) {
const x = pad.left + (i / (plotData.length - 1)) * plotW;
const y = pad.top + plotH - ((plotData[i] - minY) / (maxY - minY)) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Stroke
ctx.strokeStyle = opts.color;
ctx.lineWidth = 1.5;
ctx.stroke();
// Fill under curve
const lastX = pad.left + plotW;
const baseY = pad.top + plotH;
ctx.lineTo(lastX, baseY);
ctx.lineTo(pad.left, baseY);
ctx.closePath();
ctx.fillStyle = opts.fillColor;
ctx.fill();
// Current value indicator
const lastVal = plotData[plotData.length - 1];
const lastY = pad.top + plotH - ((lastVal - minY) / (maxY - minY)) * plotH;
ctx.beginPath();
ctx.arc(lastX, lastY, 3, 0, Math.PI * 2);
ctx.fillStyle = opts.color;
ctx.fill();
}
/**
* Destroys the charts.
*/
destroy() {
if (this.container) this.container.innerHTML = '';
this.lossCanvas = null;
this.accCanvas = null;
}
}