viz/neural/nn-visualizer.js

/**
 * @fileoverview SVG-basierter Perceptron-Visualizer.
 *
 * Verantwortlich für:
 * 1. **Scatter-Plot**           – Datenpunkte im 2D-Koordinatensystem
 * 2. **Decision Boundary**      – Linie  w₁·x + w₂·y + b = 0
 * 3. **Architektur-Diagramm**   – großes Perceptron (Inputs → Σ → θ → ŷ)
 * 4. **Step-Function Graph**    – zeigt Schwellwert und Aktivierung
 * 5. **Fehler-Highlighting**    – falsch klassifizierte Punkte pulsieren
 * 6. **Gleichungs-Anzeige**     – w₁x₁ + w₂x₂ + b = 0  und  x₂ = …
 * 7. **Fehler-Chart**           – Fehleranzahl pro Epoche
 *
 * Nutzt ausschließlich SVG (kein Canvas).
 *
 * @module nn-visualizer
 * @author Alexander Wolf
 */

'use strict';

/**
 * @class PerceptronVisualizer
 * @description Rendert Scatter-Plot, Architektur-Diagramm, Gleichungen und Fehler-Chart.
 */
class PerceptronVisualizer {

    /**
     * @param {Object} config
     * @param {string} config.plotContainerId     - ID des Plot-Container-Elements
     * @param {string} config.archContainerId     - ID des Architektur-Container-Elements
     * @param {string} [config.equationContainerId] - ID des Gleichungs-Containers
     * @param {string} [config.errorChartContainerId] - ID des Fehler-Chart-Containers
     */
    constructor(config) {
        const C = typeof NN_CONSTANTS !== 'undefined' ? NN_CONSTANTS : {};

        this._plotContainer   = document.getElementById(config.plotContainerId);
        this._archContainer   = document.getElementById(config.archContainerId);
        this._eqContainer     = config.equationContainerId
            ? document.getElementById(config.equationContainerId) : null;
        this._chartContainer  = config.errorChartContainerId
            ? document.getElementById(config.errorChartContainerId) : null;

        // Plot dimensions
        this._width   = C.PERCEPTRON_PLOT_WIDTH   || 460;
        this._height  = C.PERCEPTRON_PLOT_HEIGHT   || 340;
        this._padding = C.PERCEPTRON_PLOT_PADDING  || 40;

        // Architecture dimensions
        this._archW = C.PERCEPTRON_ARCH_WIDTH  || 600;
        this._archH = C.PERCEPTRON_ARCH_HEIGHT || 270;
        this._inputR  = C.PERCEPTRON_ARCH_INPUT_RADIUS  || 28;
        this._neuronR = C.PERCEPTRON_ARCH_NEURON_RADIUS || 36;
        this._stepW   = C.PERCEPTRON_ARCH_STEP_WIDTH    || 60;
        this._stepH   = C.PERCEPTRON_ARCH_STEP_HEIGHT   || 40;
        this._outputR = C.PERCEPTRON_ARCH_OUTPUT_RADIUS  || 28;

        // Error chart dimensions
        this._chartW = C.PERCEPTRON_ERROR_CHART_WIDTH  || 240;
        this._chartH = C.PERCEPTRON_ERROR_CHART_HEIGHT || 120;
        this._chartMaxPts = C.PERCEPTRON_ERROR_CHART_MAX_POINTS || 100;

        // Colors
        this._class0Color = C.PERCEPTRON_CLASS0_COLOR || '#3498db';
        this._class1Color = C.PERCEPTRON_CLASS1_COLOR || '#e74c3c';
        this._lineColor   = C.PERCEPTRON_DECISION_LINE_COLOR || '#2c3e50';
        this._lineWidth   = C.PERCEPTRON_DECISION_LINE_WIDTH || 2.5;
        this._pointRadius = C.PERCEPTRON_POINT_RADIUS || 6;
        this._errorColor  = C.PERCEPTRON_ERROR_PULSE_COLOR || '#f39c12';
        this._textColor   = C.PERCEPTRON_TEXT_COLOR  || '#2c3e50';
        this._mutedColor  = C.PERCEPTRON_MUTED_COLOR || '#7f8c8d';

        // Architecture layout positions
        this._archInX       = C.PERCEPTRON_ARCH_INPUT_X       || 70;
        this._archIn1Y      = C.PERCEPTRON_ARCH_INPUT1_Y      || 70;
        this._archSumX      = C.PERCEPTRON_ARCH_SUM_X         || 230;
        this._archStepX     = C.PERCEPTRON_ARCH_STEP_X        || 370;
        this._archBiasY     = C.PERCEPTRON_ARCH_BIAS_Y        || 20;
        this._archOutMargin = C.PERCEPTRON_ARCH_OUTPUT_MARGIN  || 70;

        // Font sizes
        this._fontTick      = C.PERCEPTRON_FONT_SIZE_TICK       || 11;
        this._fontAxisLabel = C.PERCEPTRON_FONT_SIZE_AXIS_LABEL || 12;
        this._fontWeight    = C.PERCEPTRON_FONT_SIZE_WEIGHT     || 11;
        this._fontNodeLabel = C.PERCEPTRON_FONT_SIZE_NODE_LABEL || 10;
        this._fontSum       = C.PERCEPTRON_FONT_SIZE_SUM        || 14;

        /** @private @type {[number,number]} */
        this._xRange = [0, 10];
        /** @private @type {[number,number]} */
        this._yRange = [0, 10];

        // SVG elements
        /** @private @type {SVGSVGElement|null} */
        this._plotSvg = null;
        /** @private @type {SVGGElement|null} */
        this._pointsGroup = null;
        /** @private @type {SVGLineElement|null} */
        this._decisionLine = null;
        /** @private @type {SVGGElement|null} */
        this._axisGroup = null;
        /** @private @type {SVGCircleElement|null} */
        this._highlightCircle = null;

        /** @private @type {PerceptronDataPoint[]} */
        this._currentPoints = [];
        /** @private @type {Set<number>} */
        this._errorIndices = new Set();
        /** @private @type {number} */
        this._selectedIdx = -1;

        // Feature labels for architecture
        /** @private */
        this._featureLabels = { x: 'x₁', y: 'x₂' };

        // Error history for chart
        /** @private @type {number[]} */
        this._errorHistory = [];

        this._initPlot();
        if (this._archContainer) {
            this._initArchDiagram();
        }
        if (this._chartContainer) {
            this._initErrorChart();
        }
    }

    // ========================================================================
    // PUBLIC API
    // ========================================================================

    /**
     * Setzt den Datensatz und zeichnet alle Punkte.
     * @param {PerceptronDataset} dataset
     */
    setDataset(dataset) {
        this._xRange = dataset.xRange;
        this._yRange = dataset.yRange;
        this._currentPoints = dataset.points;
        this._errorIndices.clear();
        this._selectedIdx = -1;
        this._errorHistory = [];

        if (dataset.featureNames) {
            this._featureLabels = dataset.featureNames;
        } else {
            this._featureLabels = dataset.axisLabels || { x: 'x₁', y: 'x₂' };
        }

        this._drawAxes(dataset.axisLabels);
        this._drawPoints();
        this._clearDecisionLine();

        if (this._archContainer) {
            this._updateArchFeatureLabels();
        }
        if (this._chartContainer) {
            this._drawErrorChart();
        }
        this._updateEquationDisplay(0, 0, 0);
    }

    /**
     * Aktualisiert die Decision-Boundary-Linie.
     * w₁·x + w₂·y + b = 0  →  y = -(w₁/w₂)·x - (b/w₂)
     *
     * @param {number} w1
     * @param {number} w2
     * @param {number} b
     */
    updateDecisionLine(w1, w2, b) {
        if (!this._decisionLine) return;

        // Beide ~0  → verstecken
        if (Math.abs(w1) < 1e-10 && Math.abs(w2) < 1e-10) {
            this._decisionLine.setAttribute('visibility', 'hidden');
            this._updateEquationDisplay(w1, w2, b);
            return;
        }

        const [xMin, xMax] = this._xRange;
        const [yMin, yMax] = this._yRange;
        const extend = (xMax - xMin) * 5;

        let lx1, ly1, lx2, ly2;

        if (Math.abs(w2) < 1e-10) {
            // Vertikale Linie: x = -b/w₁
            const xConst = -b / w1;
            lx1 = xConst; ly1 = yMin - extend;
            lx2 = xConst; ly2 = yMax + extend;
        } else {
            // Normalfall: y = -(w₁/w₂)·x - b/w₂
            lx1 = xMin - extend;
            ly1 = -(w1 / w2) * lx1 - (b / w2);
            lx2 = xMax + extend;
            ly2 = -(w1 / w2) * lx2 - (b / w2);
        }

        const clipped = this._clipLine(lx1, ly1, lx2, ly2);
        if (!clipped) {
            this._decisionLine.setAttribute('visibility', 'hidden');
            this._updateEquationDisplay(w1, w2, b);
            return;
        }

        this._decisionLine.setAttribute('x1', this._scaleX(clipped.x1));
        this._decisionLine.setAttribute('y1', this._scaleY(clipped.y1));
        this._decisionLine.setAttribute('x2', this._scaleX(clipped.x2));
        this._decisionLine.setAttribute('y2', this._scaleY(clipped.y2));
        this._decisionLine.setAttribute('visibility', 'visible');

        this._updateEquationDisplay(w1, w2, b);
    }

    /**
     * Hebt fehlklassifizierte Punkte hervor.
     * @param {number[]} errorIndices
     */
    highlightErrors(errorIndices) {
        this._errorIndices = new Set(errorIndices);
        this._updatePointStyles();
    }

    /** Entfernt alle Fehler-Highlights. */
    clearErrors() {
        this._errorIndices.clear();
        this._updatePointStyles();
    }

    /**
     * Hebt den aktuell gewählten Trainingspunkt hervor.
     * @param {number} idx  - Index in currentPoints (-1 = keiner)
     */
    highlightSelectedPoint(idx) {
        this._selectedIdx = idx;
        this._updatePointStyles();
    }

    /**
     * Aktualisiert Gewichte im Architektur-Diagramm.
     * @param {number} w1
     * @param {number} w2
     * @param {number} bias
     * @param {{ oldW1?: number, oldW2?: number, oldBias?: number }} [prev]
     */
    updateArchWeights(w1, w2, bias, prev) {
        if (!this._archContainer) return;
        const fmt = (v) => v.toFixed(3);

        const elW1 = this._archContainer.querySelector('.arch-w1');
        const elW2 = this._archContainer.querySelector('.arch-w2');
        const elB  = this._archContainer.querySelector('.arch-bias-val');

        if (prev && typeof prev.oldW1 === 'number') {
            // Show old → new transition
            if (elW1) elW1.innerHTML =
                `<tspan class="weight-old">${fmt(prev.oldW1)}</tspan> → <tspan class="weight-new">${fmt(w1)}</tspan>`;
            if (elW2) elW2.innerHTML =
                `<tspan class="weight-old">${fmt(prev.oldW2)}</tspan> → <tspan class="weight-new">${fmt(w2)}</tspan>`;
            if (elB)  elB.innerHTML =
                `<tspan class="weight-old">${fmt(prev.oldBias)}</tspan> → <tspan class="weight-new">${fmt(bias)}</tspan>`;
        } else {
            if (elW1) elW1.textContent = `w₁ = ${fmt(w1)}`;
            if (elW2) elW2.textContent = `w₂ = ${fmt(w2)}`;
            if (elB)  elB.textContent  = fmt(bias);
        }
    }

    /**
     * Zeigt Eingabewerte und Summenwert im Architektur-Diagramm.
     * @param {number|null} x1Val
     * @param {number|null} x2Val
     * @param {number|null} sumVal
     */
    updateArchInputValues(x1Val, x2Val, sumVal) {
        if (!this._archContainer) return;
        const elX1 = this._archContainer.querySelector('.arch-x1-val');
        const elX2 = this._archContainer.querySelector('.arch-x2-val');
        const elSum = this._archContainer.querySelector('.arch-sum-val');

        if (elX1) elX1.textContent = x1Val !== null ? x1Val.toFixed(1) : '';
        if (elX2) elX2.textContent = x2Val !== null ? x2Val.toFixed(1) : '';
        if (elSum) elSum.textContent = sumVal !== null ? sumVal.toFixed(2) : 'Σ';

        // Update step function indicator
        if (sumVal !== null) {
            this._updateStepIndicator(sumVal);
        }
    }

    /**
     * Setzt den Ausgabe-Wert im Architektur-Diagramm.
     * @param {string} text
     */
    updateArchOutput(text) {
        if (!this._archContainer) return;
        const el = this._archContainer.querySelector('.arch-output-val');
        if (el) el.textContent = text;
    }

    /**
     * Fügt einen Fehlerwert zur Historie hinzu und zeichnet den Chart neu.
     * @param {number} errorCount
     */
    pushErrorHistory(errorCount) {
        this._errorHistory.push(errorCount);
        if (this._errorHistory.length > this._chartMaxPts) {
            this._errorHistory.shift();
        }
        if (this._chartContainer) {
            this._drawErrorChart();
        }
    }

    /** Leert die Fehler-Historie. */
    clearErrorHistory() {
        this._errorHistory = [];
        if (this._chartContainer) {
            this._drawErrorChart();
        }
    }

    /** Zerstört die SVG-Elemente (Cleanup). */
    destroy() {
        if (this._plotContainer) this._plotContainer.innerHTML = '';
        if (this._archContainer) this._archContainer.innerHTML = '';
        if (this._eqContainer)   this._eqContainer.innerHTML = '';
        if (this._chartContainer) this._chartContainer.innerHTML = '';
    }

    // ========================================================================
    // PRIVATE  –  PLOT
    // ========================================================================

    /** @private */
    _initPlot() {
        if (!this._plotContainer) return;

        const ns = 'http://www.w3.org/2000/svg';
        const svg = document.createElementNS(ns, 'svg');
        svg.setAttribute('width', this._width);
        svg.setAttribute('height', this._height);
        svg.setAttribute('viewBox', `0 0 ${this._width} ${this._height}`);
        svg.classList.add('perceptron-plot');

        // Background
        const bg = document.createElementNS(ns, 'rect');
        bg.setAttribute('width', this._width);
        bg.setAttribute('height', this._height);
        bg.setAttribute('fill', '#fafbfc');
        bg.setAttribute('rx', '6');
        svg.appendChild(bg);

        // Axis group
        this._axisGroup = document.createElementNS(ns, 'g');
        this._axisGroup.classList.add('axes');
        svg.appendChild(this._axisGroup);

        // Decision line (behind points)
        this._decisionLine = document.createElementNS(ns, 'line');
        this._decisionLine.setAttribute('stroke', this._lineColor);
        this._decisionLine.setAttribute('stroke-width', this._lineWidth);
        this._decisionLine.setAttribute('stroke-dasharray', '8,4');
        this._decisionLine.setAttribute('visibility', 'hidden');
        svg.appendChild(this._decisionLine);

        // Points group
        this._pointsGroup = document.createElementNS(ns, 'g');
        this._pointsGroup.classList.add('points');
        svg.appendChild(this._pointsGroup);

        // Selected-point highlight ring (on top)
        this._highlightCircle = document.createElementNS(ns, 'circle');
        this._highlightCircle.setAttribute('r', this._pointRadius + 5);
        this._highlightCircle.setAttribute('fill', 'none');
        this._highlightCircle.setAttribute('stroke', this._errorColor);
        this._highlightCircle.setAttribute('stroke-width', '3');
        this._highlightCircle.setAttribute('visibility', 'hidden');
        svg.appendChild(this._highlightCircle);

        this._plotContainer.innerHTML = '';
        this._plotContainer.appendChild(svg);
        this._plotSvg = svg;
    }

    /**
     * @private
     * @param {{ x: string, y: string }} axisLabels - Achsenbeschriftungen
     */
    _drawAxes(axisLabels) {
        if (!this._axisGroup) return;
        this._axisGroup.innerHTML = '';

        const p  = this._padding;
        const w  = this._width;
        const h  = this._height;
        const ns = 'http://www.w3.org/2000/svg';
        const tickCount = 5;

        // x-Achse
        const xAxis = document.createElementNS(ns, 'line');
        xAxis.setAttribute('x1', p);
        xAxis.setAttribute('y1', h - p);
        xAxis.setAttribute('x2', w - p);
        xAxis.setAttribute('y2', h - p);
        xAxis.setAttribute('stroke', '#bdc3c7');
        xAxis.setAttribute('stroke-width', '1');
        this._axisGroup.appendChild(xAxis);

        // y-Achse
        const yAxis = document.createElementNS(ns, 'line');
        yAxis.setAttribute('x1', p);
        yAxis.setAttribute('y1', p);
        yAxis.setAttribute('x2', p);
        yAxis.setAttribute('y2', h - p);
        yAxis.setAttribute('stroke', '#bdc3c7');
        yAxis.setAttribute('stroke-width', '1');
        this._axisGroup.appendChild(yAxis);

        const [xMin, xMax] = this._xRange;
        const [yMin, yMax] = this._yRange;

        for (let i = 0; i <= tickCount; i++) {
            const ratio = i / tickCount;

            // x-Ticks
            const xVal = xMin + ratio * (xMax - xMin);
            const sx = p + ratio * (w - 2 * p);
            const xTick = document.createElementNS(ns, 'text');
            xTick.setAttribute('x', sx);
            xTick.setAttribute('y', h - p + 18);
            xTick.setAttribute('text-anchor', 'middle');
            xTick.setAttribute('font-size', String(this._fontTick));
            xTick.setAttribute('fill', this._mutedColor);
            xTick.textContent = xVal.toFixed(0);
            this._axisGroup.appendChild(xTick);

            // y-Ticks
            const yVal = yMin + ratio * (yMax - yMin);
            const sy = h - p - ratio * (h - 2 * p);
            const yTick = document.createElementNS(ns, 'text');
            yTick.setAttribute('x', p - 8);
            yTick.setAttribute('y', sy + 4);
            yTick.setAttribute('text-anchor', 'end');
            yTick.setAttribute('font-size', String(this._fontTick));
            yTick.setAttribute('fill', this._mutedColor);
            yTick.textContent = yVal.toFixed(0);
            this._axisGroup.appendChild(yTick);
        }

        if (axisLabels) {
            const xLabel = document.createElementNS(ns, 'text');
            xLabel.setAttribute('x', w / 2);
            xLabel.setAttribute('y', h - 4);
            xLabel.setAttribute('text-anchor', 'middle');
            xLabel.setAttribute('font-size', String(this._fontAxisLabel));
            xLabel.setAttribute('fill', this._textColor);
            xLabel.setAttribute('font-weight', '600');
            xLabel.textContent = axisLabels.x;
            this._axisGroup.appendChild(xLabel);

            const yLabel = document.createElementNS(ns, 'text');
            yLabel.setAttribute('x', 14);
            yLabel.setAttribute('y', h / 2);
            yLabel.setAttribute('text-anchor', 'middle');
            yLabel.setAttribute('font-size', String(this._fontAxisLabel));
            yLabel.setAttribute('fill', this._textColor);
            yLabel.setAttribute('font-weight', '600');
            yLabel.setAttribute('transform', `rotate(-90, 14, ${h / 2})`);
            yLabel.textContent = axisLabels.y;
            this._axisGroup.appendChild(yLabel);
        }
    }

    /** @private */
    _drawPoints() {
        if (!this._pointsGroup) return;
        this._pointsGroup.innerHTML = '';

        const ns = 'http://www.w3.org/2000/svg';

        this._currentPoints.forEach((pt, idx) => {
            const cx = this._scaleX(pt.x);
            const cy = this._scaleY(pt.y);

            const circle = document.createElementNS(ns, 'circle');
            circle.setAttribute('cx', cx);
            circle.setAttribute('cy', cy);
            circle.setAttribute('r', this._pointRadius);
            circle.setAttribute('fill', pt.label === 0 ? this._class0Color : this._class1Color);
            circle.setAttribute('stroke', '#fff');
            circle.setAttribute('stroke-width', '1.5');
            circle.setAttribute('data-idx', idx);
            circle.classList.add('data-point');

            const title = document.createElementNS(ns, 'title');
            title.textContent = `(${pt.x}, ${pt.y}) → ${pt.label}`;
            circle.appendChild(title);

            this._pointsGroup.appendChild(circle);
        });
    }

    /** @private */
    _updatePointStyles() {
        if (!this._pointsGroup) return;

        const circles = this._pointsGroup.querySelectorAll('.data-point');
        circles.forEach((circle) => {
            const idx = parseInt(circle.getAttribute('data-idx'), 10);
            const isError = this._errorIndices.has(idx);
            const isSelected = idx === this._selectedIdx;

            if (isError) {
                circle.classList.add('error-pulse');
                circle.setAttribute('r', this._pointRadius + 2);
                circle.setAttribute('stroke', this._errorColor);
                circle.setAttribute('stroke-width', '2.5');
            } else {
                circle.classList.remove('error-pulse');
                circle.setAttribute('r', this._pointRadius);
                circle.setAttribute('stroke', '#fff');
                circle.setAttribute('stroke-width', '1.5');
            }

            // Selected point highlight ring
            if (isSelected && this._highlightCircle) {
                const cx = circle.getAttribute('cx');
                const cy = circle.getAttribute('cy');
                this._highlightCircle.setAttribute('cx', cx);
                this._highlightCircle.setAttribute('cy', cy);
                this._highlightCircle.setAttribute('visibility', 'visible');
            }
        });

        // Hide highlight if no selection
        if (this._selectedIdx < 0 && this._highlightCircle) {
            this._highlightCircle.setAttribute('visibility', 'hidden');
        }
    }

    /** @private */
    _clearDecisionLine() {
        if (this._decisionLine) {
            this._decisionLine.setAttribute('visibility', 'hidden');
        }
    }

    // ========================================================================
    // PRIVATE  –  COORDINATE TRANSFORMS & LINE CLIPPING
    // ========================================================================

    /**
     * @private
     * @param {number} x
     * @returns {number}
     */
    _scaleX(x) {
        const [min, max] = this._xRange;
        const plotW = this._width - 2 * this._padding;
        return this._padding + ((x - min) / (max - min)) * plotW;
    }

    /**
     * @private
     * @param {number} y
     * @returns {number}
     */
    _scaleY(y) {
        const [min, max] = this._yRange;
        const plotH = this._height - 2 * this._padding;
        return this._height - this._padding - ((y - min) / (max - min)) * plotH;
    }

    /**
     * Cohen-Sutherland Line Clipping.
     * @private
     * @param {number} x1
     * @param {number} y1
     * @param {number} x2
     * @param {number} y2
     * @returns {{ x1:number, y1:number, x2:number, y2:number }|null}
     */
    _clipLine(x1, y1, x2, y2) {
        const [xMin, xMax] = this._xRange;
        const [yMin, yMax] = this._yRange;

        const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8;

        const code = (x, y) => {
            let c = INSIDE;
            if (x < xMin)      c |= LEFT;
            else if (x > xMax) c |= RIGHT;
            if (y < yMin)      c |= BOTTOM;
            else if (y > yMax) c |= TOP;
            return c;
        };

        let c1 = code(x1, y1);
        let c2 = code(x2, y2);

        for (let i = 0; i < 20; i++) {
            if (!(c1 | c2)) return { x1, y1, x2, y2 };
            if (c1 & c2)    return null;

            const cOut = c1 || c2;
            let x = 0, y = 0;
            if (cOut & TOP)        { x = x1 + (x2 - x1) * (yMax - y1) / (y2 - y1); y = yMax; }
            else if (cOut & BOTTOM) { x = x1 + (x2 - x1) * (yMin - y1) / (y2 - y1); y = yMin; }
            else if (cOut & RIGHT)  { y = y1 + (y2 - y1) * (xMax - x1) / (x2 - x1); x = xMax; }
            else                    { y = y1 + (y2 - y1) * (xMin - x1) / (x2 - x1); x = xMin; }

            if (cOut === c1) { x1 = x; y1 = y; c1 = code(x1, y1); }
            else             { x2 = x; y2 = y; c2 = code(x2, y2); }
        }
        return null;
    }

    // ========================================================================
    // PRIVATE  –  ARCHITECTURE DIAGRAM
    // ========================================================================

    /** @private */
    _initArchDiagram() {
        if (!this._archContainer) return;

        const W = this._archW;
        const H = this._archH;
        const ir = this._inputR;
        const nr = this._neuronR;
        const or = this._outputR;

        // Positions
        const inX = this._archInX;
        const in1Y = this._archIn1Y;
        const in2Y = H - this._archIn1Y;
        const sumX = this._archSumX;
        const sumY = H / 2;
        const stepX = this._archStepX;
        const stepY = H / 2;
        const outX = W - this._archOutMargin;
        const outY = H / 2;
        const biasY = this._archBiasY;

        this._archContainer.innerHTML = `
        <svg class="arch-svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
            <defs>
                <marker id="arch-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
                    <path d="M0,0 L8,3 L0,6 Z" fill="${this._textColor}"/>
                </marker>
                <marker id="arch-arrow-muted" markerWidth="6" markerHeight="5" refX="6" refY="2.5" orient="auto">
                    <path d="M0,0 L6,2.5 L0,5 Z" fill="#95a5a6"/>
                </marker>
            </defs>

            <!-- ===== WEIGHT LINES ===== -->
            <line x1="${inX + ir}" y1="${in1Y}" x2="${sumX - nr}" y2="${sumY}"
                  stroke="${this._mutedColor}" stroke-width="2"/>
            <line x1="${inX + ir}" y1="${in2Y}" x2="${sumX - nr}" y2="${sumY}"
                  stroke="${this._mutedColor}" stroke-width="2"/>

            <!-- Weight labels -->
            <text class="arch-w1" x="${(inX + sumX) / 2}" y="${in1Y - 10}"
                  text-anchor="middle" font-size="${this._fontWeight}" fill="${this._textColor}" font-weight="600">w₁ = 0.000</text>
            <text class="arch-w2" x="${(inX + sumX) / 2}" y="${in2Y + 18}"
                  text-anchor="middle" font-size="${this._fontWeight}" fill="${this._textColor}" font-weight="600">w₂ = 0.000</text>

            <!-- ===== INPUT NEURONS ===== -->
            <circle cx="${inX}" cy="${in1Y}" r="${ir}" fill="#ecf0f1" stroke="${this._class0Color}" stroke-width="2"/>
            <text class="arch-feat1-label" x="${inX}" y="${in1Y - ir - 6}"
                  text-anchor="middle" font-size="${this._fontNodeLabel}" fill="${this._mutedColor}" font-weight="600">x₁</text>
            <text class="arch-x1-val" x="${inX}" y="${in1Y + 5}"
                  text-anchor="middle" font-size="${this._fontSum}" font-weight="700" fill="${this._textColor}"></text>

            <circle cx="${inX}" cy="${in2Y}" r="${ir}" fill="#ecf0f1" stroke="${this._class0Color}" stroke-width="2"/>
            <text class="arch-feat2-label" x="${inX}" y="${in2Y - ir - 6}"
                  text-anchor="middle" font-size="${this._fontNodeLabel}" fill="${this._mutedColor}" font-weight="600">x₂</text>
            <text class="arch-x2-val" x="${inX}" y="${in2Y + 5}"
                  text-anchor="middle" font-size="${this._fontSum}" font-weight="700" fill="${this._textColor}"></text>

            <!-- ===== BIAS ===== -->
            <line x1="${sumX}" y1="${biasY + 14}" x2="${sumX}" y2="${sumY - nr}"
                  stroke="#95a5a6" stroke-width="1.5" stroke-dasharray="4,3"
                  marker-end="url(#arch-arrow-muted)"/>
            <text x="${sumX}" y="${biasY}" text-anchor="middle" font-size="${this._fontNodeLabel}" fill="#95a5a6">Bias</text>
            <text class="arch-bias-val" x="${sumX + 22}" y="${biasY + 12}"
                  text-anchor="start" font-size="${this._fontNodeLabel}" fill="${this._textColor}" font-weight="600">0.000</text>

            <!-- ===== SUM NEURON (Σ) ===== -->
            <circle cx="${sumX}" cy="${sumY}" r="${nr}" fill="#ecf0f1" stroke="${this._textColor}" stroke-width="2.5"/>
            <text class="arch-sum-val" x="${sumX}" y="${sumY + 6}"
                  text-anchor="middle" font-size="18" font-weight="700" fill="${this._textColor}">Σ</text>

            <!-- Arrow Sum → Step -->
            <line x1="${sumX + nr}" y1="${sumY}" x2="${stepX - this._stepW / 2 - 8}" y2="${sumY}"
                  stroke="${this._textColor}" stroke-width="2" marker-end="url(#arch-arrow)"/>

            <!-- ===== STEP FUNCTION GRAPH (θ) ===== -->
            <g class="arch-step-graph" transform="translate(${stepX - this._stepW / 2}, ${stepY - this._stepH / 2})">
                <!-- Axes -->
                <line x1="0" y1="${this._stepH}" x2="${this._stepW}" y2="${this._stepH}"
                      stroke="#bdc3c7" stroke-width="1"/>
                <line x1="${this._stepW / 2}" y1="0" x2="${this._stepW / 2}" y2="${this._stepH}"
                      stroke="#bdc3c7" stroke-width="1"/>
                <!-- Step curve: left half (y=0), right half (y=1) -->
                <polyline points="0,${this._stepH} ${this._stepW / 2},${this._stepH} ${this._stepW / 2},${4} ${this._stepW},${4}"
                          fill="none" stroke="${this._textColor}" stroke-width="2.5"/>
                <!-- Indicator dot (hidden by default) -->
                <circle class="step-indicator" cx="${this._stepW / 2}" cy="${this._stepH}" r="4"
                        fill="${this._errorColor}" visibility="hidden"/>
                <!-- Label -->
                <text x="${this._stepW / 2}" y="${this._stepH + 16}"
                      text-anchor="middle" font-size="9" fill="${this._mutedColor}">θ(x)</text>
            </g>

            <!-- Arrow Step → Output -->
            <line x1="${stepX + this._stepW / 2 + 4}" y1="${sumY}" x2="${outX - or - 8}" y2="${outY}"
                  stroke="${this._textColor}" stroke-width="2" marker-end="url(#arch-arrow)"/>

            <!-- ===== OUTPUT NEURON ===== -->
            <circle cx="${outX}" cy="${outY}" r="${or}" fill="#ecf0f1" stroke="${this._class1Color}" stroke-width="2"/>
            <text class="arch-output-val" x="${outX}" y="${outY + 6}"
                  text-anchor="middle" font-size="16" font-weight="700" fill="${this._class1Color}">ŷ</text>
        </svg>`;
    }

    /**
     * @private
     * @param {number} sumVal - Summenwert des Neurons
     */
    _updateStepIndicator(sumVal) {
        if (!this._archContainer) return;
        const dot = this._archContainer.querySelector('.step-indicator');
        if (!dot) return;

        // Map sum value to x position in step graph
        // Negative sums go left, positive go right
        const halfW = this._stepW / 2;
        const clampedSum = Math.max(-5, Math.min(5, sumVal));
        const xPos = halfW + (clampedSum / 5) * halfW;
        const yPos = sumVal >= 0 ? 4 : this._stepH;

        dot.setAttribute('cx', xPos);
        dot.setAttribute('cy', yPos);
        dot.setAttribute('visibility', 'visible');
    }

    /** @private  –  Updates feature labels in architecture diagram */
    _updateArchFeatureLabels() {
        if (!this._archContainer) return;
        const el1 = this._archContainer.querySelector('.arch-feat1-label');
        const el2 = this._archContainer.querySelector('.arch-feat2-label');
        if (el1) el1.textContent = this._featureLabels.x;
        if (el2) el2.textContent = this._featureLabels.y;
    }

    // ========================================================================
    // PRIVATE  –  EQUATION DISPLAY
    // ========================================================================

    /**
     * @private
     * @param {number} w1
     * @param {number} w2
     * @param {number} b
     */
    _updateEquationDisplay(w1, w2, b) {
        if (!this._eqContainer) return;

        const fmt = (v) => {
            return v >= 0 ? `+ ${v.toFixed(2)}` : `− ${Math.abs(v).toFixed(2)}`;
        };

        // Compact inline: show explicit form if possible, else implicit
        if (Math.abs(w2) > 1e-10) {
            const slope = -(w1 / w2);
            const intercept = -(b / w2);
            const ic = intercept >= 0 ? `+ ${intercept.toFixed(2)}` : `− ${Math.abs(intercept).toFixed(2)}`;
            this._eqContainer.textContent =
                `${this._featureLabels.y} = ${slope.toFixed(2)}·${this._featureLabels.x} ${ic}`;
        } else if (Math.abs(w1) > 1e-10) {
            this._eqContainer.textContent = `${this._featureLabels.x} = ${(-b / w1).toFixed(2)}`;
        } else {
            this._eqContainer.textContent = '';
        }
    }

    // ========================================================================
    // PRIVATE  –  ERROR CHART
    // ========================================================================

    /** @private */
    _initErrorChart() {
        // Will be drawn when data arrives
    }

    /** @private */
    _drawErrorChart() {
        if (!this._chartContainer) return;

        // Use container width or fallback
        const containerRect = this._chartContainer.getBoundingClientRect();
        const W = Math.max(containerRect.width || this._chartW, 160);
        const H = this._chartH;
        const pad = 30;
        const ns = 'http://www.w3.org/2000/svg';

        this._chartContainer.innerHTML = '';

        const svg = document.createElementNS(ns, 'svg');
        svg.setAttribute('width', W);
        svg.setAttribute('height', H);
        svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
        svg.classList.add('error-chart-svg');

        // Background
        const bg = document.createElementNS(ns, 'rect');
        bg.setAttribute('width', W);
        bg.setAttribute('height', H);
        bg.setAttribute('fill', '#fafafa');
        bg.setAttribute('rx', '4');
        svg.appendChild(bg);

        // Axes
        const xAxis = document.createElementNS(ns, 'line');
        xAxis.setAttribute('x1', pad);
        xAxis.setAttribute('y1', H - pad);
        xAxis.setAttribute('x2', W - 10);
        xAxis.setAttribute('y2', H - pad);
        xAxis.setAttribute('stroke', '#bdc3c7');
        svg.appendChild(xAxis);

        const yAxis = document.createElementNS(ns, 'line');
        yAxis.setAttribute('x1', pad);
        yAxis.setAttribute('y1', 10);
        yAxis.setAttribute('x2', pad);
        yAxis.setAttribute('y2', H - pad);
        yAxis.setAttribute('stroke', '#bdc3c7');
        svg.appendChild(yAxis);

        // Labels
        const xLabel = document.createElementNS(ns, 'text');
        xLabel.setAttribute('x', W / 2);
        xLabel.setAttribute('y', H - 4);
        xLabel.setAttribute('text-anchor', 'middle');
        xLabel.setAttribute('font-size', '9');
        xLabel.setAttribute('fill', this._mutedColor);
        xLabel.textContent = 'Epoche';
        svg.appendChild(xLabel);

        const yLabel = document.createElementNS(ns, 'text');
        yLabel.setAttribute('x', 8);
        yLabel.setAttribute('y', H / 2);
        yLabel.setAttribute('text-anchor', 'middle');
        yLabel.setAttribute('font-size', '9');
        yLabel.setAttribute('fill', this._mutedColor);
        yLabel.setAttribute('transform', `rotate(-90, 8, ${H / 2})`);
        yLabel.textContent = 'Fehler';
        svg.appendChild(yLabel);

        // Draw data
        const data = this._errorHistory;
        if (data.length > 1) {
            const maxVal = Math.max(...data, 1);
            const plotW = W - pad - 10;
            const plotH = H - pad - 10;

            let pathD = '';
            data.forEach((val, i) => {
                const x = pad + (i / (data.length - 1)) * plotW;
                const y = (H - pad) - (val / maxVal) * plotH;
                pathD += (i === 0 ? 'M' : 'L') + `${x},${y} `;
            });

            const path = document.createElementNS(ns, 'path');
            path.setAttribute('d', pathD);
            path.setAttribute('fill', 'none');
            path.setAttribute('stroke', this._class1Color);
            path.setAttribute('stroke-width', '2');
            path.setAttribute('stroke-linejoin', 'round');
            svg.appendChild(path);
        }

        this._chartContainer.appendChild(svg);
    }
}


// ===== Universeller Export =====
(function (root) {
    root.PerceptronVisualizer = PerceptronVisualizer;
})(typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : this);