viz/neural/nn-training-charts.js

/**
 * @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;
    }
}