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