/**
* @fileoverview NumericNodeRenderer - Renderer für numerische Knoten im Diagramm-Modus
*
* Rendert Baumknoten als Kreise mit zentriertem Zahlenwert, analog zu
* Lehrbuch-Darstellungen von Minimax- und Alpha-Beta-Bäumen.
*
* Visuelles Layout (v2):
* - Statusbasierter Außenring (READY = grün, ACTIVE = gelb, etc.)
* - Kreis-Füllung basierend auf dem evaluierten Wert:
* • value > 0: rötlich (MAX-Vorteil)
* • value < 0: bläulich (MIN-Vorteil)
* • value = 0: grau (Unentschieden)
* • unevaluiert: weiß
* - Unter dem Kreis: farbkodierte α/β-Werte (α rot, β blau)
* mit Cutoff-Indikator wenn α ≥ β
* - Rollenlabel (MAX/MIN) ENTFERNT – redundant mit Ebenen-Overlay
* - Schriftgröße skaliert proportional zum Knotenradius
*
* Separation of Concerns:
* - Kein Wissen über TicTacToe, Knights-Tour oder andere Spiele.
* - Reagiert ausschließlich auf boardType: 'numeric' und NumericNodeMetadata.
* - Farben und Dimensionen aus NUMERIC_VIZ_CONSTANTS (zentrale Parameterhaltung).
* - Status-Ring nutzt style-Objekt aus TreeFeaturesEngine (status-config.js).
*
* Wird von TreeRenderer.renderBoardNode() aufgerufen, wenn boardType === 'numeric'.
*
* @author Alexander Wolf
* @version 2.0
* @see ENGINEERING_CONVENTIONS.md – Konventionen 2 (JSDoc), 3 (Magic Numbers), 4 (Parameterhaltung)
*/
/**
* @typedef {Object} NumericNodeMetadata
* @property {number|null} value - Numerischer Knotenwert (Minimax-Score)
* @property {boolean} isMaximizing - true = MAX-Knoten, false = MIN-Knoten
* @property {boolean} [isPruned] - Wurde der Knoten durch Pruning abgeschnitten?
* @property {number} [alpha] - Alpha-Schwellenwert (nur bei Alpha-Beta)
* @property {number} [beta] - Beta-Schwellenwert (nur bei Alpha-Beta)
* @property {boolean} [showAlphaBeta] - Explizites Gate: false unterdrückt α/β-Anzeige (Default: true)
* @property {number} [depth] - Tiefe im Baum
* @property {boolean} [isTerminal] - Ist der Knoten ein Blattknoten?
* @property {string} [label] - Optionales Textlabel (z.B. "A", "B1")
*/
/**
* @typedef {Object} NodeRenderStyle
* @property {string} fillColor - Füllfarbe aus Status-Config
* @property {string} borderColor - Randfarbe aus Status-Config (z.B. grün für READY)
* @property {number} borderWidth - Randbreite aus Status-Config
* @property {string} [glowColor] - Glow-Farbe (optional)
* @property {number[]} [borderDash] - Strichmuster für den Rand
*/
/**
* NumericNodeRenderer – Rendert numerische Knoten als Kreise mit Wert.
* @namespace
*/
var NumericNodeRenderer = {
// ========================================================================
// KONSTANTEN
// ========================================================================
/** Verhältnis Schriftgröße zu Radius für den Zahlenwert im Kreis */
VALUE_FONT_RATIO: 0.65,
/** Verhältnis Schriftgröße zu Radius für α/β-Text */
AB_FONT_RATIO: 0.35,
/** Mindest-Schriftgröße in Welt-Koordinaten */
MIN_FONT_SIZE: 6,
/** Abstand des Status-Rings zur Kreiskante */
STATUS_RING_GAP: 3,
/** Zusätzliche Breite des Status-Rings */
STATUS_RING_WIDTH: 3,
/** Intensitätsfaktor für die wertbasierte Einfärbung (0-1) */
VALUE_COLOR_INTENSITY: 0.35,
/** Maximaler Wert für die Farbsättigung (Werte darüber = volle Intensität) */
VALUE_COLOR_CLAMP: 10,
/**
* Löst Konstanten mit robusten Fallbacks auf.
* @returns {Object} Aufgelöste Konstanten für Circle, Font, Spacing, Colors
* @private
*/
_resolveConstants() {
var viz = typeof NUMERIC_VIZ_CONSTANTS !== 'undefined' ? NUMERIC_VIZ_CONSTANTS : {};
var circle = viz.CIRCLE || {};
var font = viz.FONT || {};
var spacing = viz.SPACING || {};
var colors = viz.COLORS || {};
return {
minRadius: circle.MIN_RADIUS || 18,
valuePadding: circle.VALUE_PADDING || 0.6,
borderWidth: circle.BORDER_WIDTH || 2,
fontFamily: font.FAMILY || 'Arial, sans-serif',
monoFamily: font.MONO_FAMILY || 'monospace',
alphaBetaOffset: spacing.ALPHA_BETA_OFFSET || 5,
maxAccent: colors.MAX_ACCENT || '#e74c3c',
maxFill: colors.MAX_FILL || '#fde8e8',
minAccent: colors.MIN_ACCENT || '#3498db',
minFill: colors.MIN_FILL || '#e8f0fd',
neutralFill: colors.NEUTRAL_FILL || '#ffffff',
neutralBorder: colors.NEUTRAL_BORDER || '#bdc3c7',
valueText: colors.VALUE_TEXT || '#2c3e50',
prunedFill: colors.PRUNED_FILL || '#f2f2f2',
prunedBorder: colors.PRUNED_BORDER || '#999999',
placeholderText: colors.PLACEHOLDER_TEXT || '#aaaaaa',
drawFill: colors.DRAW_FILL || '#ecf0f1',
};
},
// ========================================================================
// HAUPT-RENDER
// ========================================================================
/**
* Rendert einen numerischen Knoten als Kreis mit Wert.
*
* @param {CanvasRenderingContext2D} ctx - Canvas-Kontext (Viewport-Transform bereits angewendet)
* @param {Object} boardData - Board-Daten mit {value}
* @param {number} centerX - Zentrum X-Koordinate des Knotens
* @param {number} centerY - Zentrum Y-Koordinate des Knotens
* @param {number} size - Größe des Knotens in Pixeln (Durchmesser-Äquivalent)
* @param {number} scale - Aktuelle Viewport-Skalierung
* @param {NumericNodeMetadata} metadata - Numerische Metadaten
* @param {NodeRenderStyle} [style] - Status-basierter Render-Stil aus TreeFeaturesEngine
*/
render(ctx, boardData, centerX, centerY, size, scale, metadata, style) {
if (!metadata) metadata = {};
if (!style) style = {};
var c = this._resolveConstants();
var radius = Math.max(c.minRadius, size / 2);
var isPruned = metadata.isPruned === true;
// Wert auslesen (boardData hat Priorität)
var value = (boardData && boardData.value !== undefined && boardData.value !== null)
? boardData.value
: (metadata.value !== undefined && metadata.value !== null ? metadata.value : null);
var hasValue = (value !== null && value !== undefined);
ctx.save();
// --- 1. Status-Ring (READY=grün, ACTIVE=gelb, etc.) ---
this._drawStatusRing(ctx, centerX, centerY, radius, style, scale, c);
// --- 2. Hauptkreis zeichnen ---
this._drawMainCircle(ctx, centerX, centerY, radius, value, hasValue, isPruned, metadata, scale, c);
// --- 3. Wert im Kreis zeichnen (proportional zum Radius) ---
this._drawValue(ctx, centerX, centerY, radius, value, hasValue, isPruned, c);
// --- 4. Alpha/Beta unter dem Kreis (farbkodiert) ---
this._drawAlphaBeta(ctx, centerX, centerY, radius, metadata, scale, c);
ctx.restore();
},
// ========================================================================
// RENDERING-METHODEN
// ========================================================================
/**
* Zeichnet den Status-Ring außerhalb des Hauptkreises.
* Nutzt die Farbe aus dem status-config Style-Objekt.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} cx - Zentrum X
* @param {number} cy - Zentrum Y
* @param {number} radius - Kreisradius
* @param {NodeRenderStyle} style - Status-Stil
* @param {number} scale - Viewport-Skalierung
* @param {Object} c - Aufgelöste Konstanten
* @private
*/
_drawStatusRing(ctx, cx, cy, radius, style, scale, c) {
// Nur zeichnen wenn Status eine sichtbare Farbe liefert
var borderColor = style.borderColor;
if (!borderColor || borderColor === 'transparent' || borderColor === c.neutralBorder) return;
var ringRadius = radius + this.STATUS_RING_GAP;
var ringWidth = Math.max(1.5, this.STATUS_RING_WIDTH / scale);
// Glow-Effekt für READY/ACTIVE
if (style.glowColor) {
ctx.shadowBlur = 12 / scale;
ctx.shadowColor = style.glowColor;
}
ctx.strokeStyle = borderColor;
ctx.lineWidth = ringWidth;
if (style.borderDash && style.borderDash.length > 0) {
ctx.setLineDash(style.borderDash.map(function(d) { return d / scale; }));
} else {
ctx.setLineDash([]);
}
ctx.beginPath();
ctx.arc(cx, cy, ringRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.shadowBlur = 0;
},
/**
* Zeichnet den Hauptkreis mit wertbasierter Füllung.
*
* Farblogik:
* - Nicht evaluiert → weiß (neutral)
* - value > 0 → rötlich (MAX-Vorteil, Intensität proportional zum Wert)
* - value < 0 → bläulich (MIN-Vorteil, Intensität proportional zum |Wert|)
* - value = 0 → hellgrau (Unentschieden)
* - Gepruned → grau mit gestricheltem Rand
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} cx - Zentrum X
* @param {number} cy - Zentrum Y
* @param {number} radius - Kreisradius
* @param {number|null} value - Evaluierter Wert
* @param {boolean} hasValue - Ob ein Wert vorhanden ist
* @param {boolean} isPruned - Ob der Knoten gepruned wurde
* @param {NumericNodeMetadata} metadata
* @param {number} scale - Viewport-Skalierung
* @param {Object} c - Aufgelöste Konstanten
* @private
*/
_drawMainCircle(ctx, cx, cy, radius, value, hasValue, isPruned, metadata, scale, c) {
var fillColor;
var borderColor;
if (isPruned) {
fillColor = c.prunedFill;
borderColor = c.prunedBorder;
} else if (hasValue) {
fillColor = this._getValueColor(value, c);
// Dünner Rand in Rollenfarbe als dezenter Hinweis
if (metadata.isMaximizing === true) {
borderColor = c.maxAccent;
} else if (metadata.isMaximizing === false) {
borderColor = c.minAccent;
} else {
borderColor = c.neutralBorder;
}
} else {
fillColor = c.neutralFill;
borderColor = c.neutralBorder;
}
// Füllung
ctx.fillStyle = fillColor;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
// Rand
ctx.strokeStyle = borderColor;
ctx.lineWidth = Math.max(1, c.borderWidth / scale);
if (isPruned) {
ctx.setLineDash([4 / scale, 3 / scale]);
} else {
ctx.setLineDash([]);
}
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
},
/**
* Berechnet die Füllfarbe basierend auf dem evaluierten Wert.
* Verwendet einen linearen Farbverlauf: rot (positiv) / blau (negativ) / grau (null).
*
* @param {number} value - Evaluierter Knotenwert
* @param {Object} c - Aufgelöste Konstanten
* @returns {string} CSS-Farbstring
* @private
*/
_getValueColor(value, c) {
if (value === 0) return c.drawFill;
// Intensität: linear von 0 bis VALUE_COLOR_CLAMP, dann geclampt
var absVal = Math.abs(value);
var t = Math.min(absVal / this.VALUE_COLOR_CLAMP, 1) * this.VALUE_COLOR_INTENSITY;
if (value > 0) {
// Rötlich: rgba(231, 76, 60, t) über weiß
return 'rgba(231, 76, 60, ' + t.toFixed(3) + ')';
} else {
// Bläulich: rgba(52, 152, 219, t) über weiß
return 'rgba(52, 152, 219, ' + t.toFixed(3) + ')';
}
},
/**
* Zeichnet den Zahlenwert (oder ?) im Kreis.
* Schriftgröße skaliert proportional zum Knotenradius.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} cx - Zentrum X
* @param {number} cy - Zentrum Y
* @param {number} radius - Kreisradius
* @param {number|null} value - Wert
* @param {boolean} hasValue - Ob ein Wert vorhanden ist
* @param {boolean} isPruned - Gepruned?
* @param {Object} c - Aufgelöste Konstanten
* @private
*/
_drawValue(ctx, cx, cy, radius, value, hasValue, isPruned, c) {
// Proportional zum Radius statt fix: skaliert korrekt beim Zoomen
var fontSize = Math.max(this.MIN_FONT_SIZE, radius * this.VALUE_FONT_RATIO);
ctx.font = 'bold ' + fontSize + 'px ' + c.fontFamily;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (hasValue) {
// Wert anzeigen, auch bei geprunten Knoten (dort in gedämpfter Farbe)
ctx.fillStyle = isPruned ? c.prunedBorder : c.valueText;
var displayValue = (typeof value === 'number')
? (Number.isInteger(value) ? value.toString() : value.toFixed(1))
: String(value);
ctx.fillText(displayValue, cx, cy);
} else if (isPruned) {
// Geprunte Knoten ohne Wert: Kreis bleibt leer.
// Dashed Border + grauer Hintergrund zeigen den Pruning-Status bereits an.
} else {
ctx.fillStyle = c.placeholderText;
ctx.fillText('?', cx, cy);
}
},
/**
* Zeichnet farbkodierte Alpha/Beta-Werte unterhalb des Kreises.
*
* Farbkodierung:
* - α-Wert in MAX-Farbe (rot) – α ist die Untergrenze des MAX-Spielers
* - β-Wert in MIN-Farbe (blau) – β ist die Obergrenze des MIN-Spielers
* - Bei Cutoff (α ≥ β): roter Hintergrund-Pill als Warnung
*
* Wird nur angezeigt wenn:
* 1. showAlphaBeta !== false (Adapter-Gate)
* 2. Sowohl alpha als auch beta in metadata definiert sind
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} cx - Zentrum X
* @param {number} cy - Zentrum Y
* @param {number} radius - Kreisradius
* @param {NumericNodeMetadata} metadata - Mit alpha, beta, showAlphaBeta
* @param {number} scale - Viewport-Skalierung
* @param {Object} c - Aufgelöste Konstanten
* @private
*/
_drawAlphaBeta(ctx, cx, cy, radius, metadata, scale, c) {
// Explizites Opt-out durch Adapter (z.B. wenn AB deaktiviert)
if (metadata.showAlphaBeta === false) return;
// Alpha/Beta nur zeichnen wenn beide definiert
if (metadata.alpha === undefined || metadata.beta === undefined) return;
var fontSize = Math.max(this.MIN_FONT_SIZE, radius * this.AB_FONT_RATIO);
var textY = cy + radius + (c.alphaBetaOffset / scale);
var isCutoff = (metadata.alpha >= metadata.beta);
var alphaStr = (metadata.alpha === -Infinity) ? '-∞' : metadata.alpha.toFixed(1);
var betaStr = (metadata.beta === Infinity) ? '∞' : metadata.beta.toFixed(1);
var fullText = 'α:' + alphaStr + ' β:' + betaStr;
// Cutoff-Indikator: roter Hintergrund-Pill
if (isCutoff) {
ctx.font = fontSize + 'px ' + c.monoFamily;
var textWidth = ctx.measureText(fullText).width;
var pillPadX = fontSize * 0.4;
var pillPadY = fontSize * 0.25;
var pillH = fontSize + pillPadY * 2;
var pillW = textWidth + pillPadX * 2;
var pillR = pillH / 2;
ctx.fillStyle = 'rgba(231, 76, 60, 0.15)';
ctx.beginPath();
// Rounded rect (pill shape)
ctx.moveTo(cx - pillW / 2 + pillR, textY - pillPadY);
ctx.arcTo(cx + pillW / 2, textY - pillPadY, cx + pillW / 2, textY + pillH - pillPadY, pillR);
ctx.arcTo(cx + pillW / 2, textY + pillH - pillPadY, cx - pillW / 2, textY + pillH - pillPadY, pillR);
ctx.arcTo(cx - pillW / 2, textY + pillH - pillPadY, cx - pillW / 2, textY - pillPadY, pillR);
ctx.arcTo(cx - pillW / 2, textY - pillPadY, cx + pillW / 2, textY - pillPadY, pillR);
ctx.closePath();
ctx.fill();
// Cutoff-Rand
ctx.strokeStyle = 'rgba(231, 76, 60, 0.4)';
ctx.lineWidth = Math.max(0.5, 1 / scale);
ctx.stroke();
}
// α-Wert in Rot (MAX-Farbe)
ctx.font = fontSize + 'px ' + c.monoFamily;
ctx.textBaseline = 'top';
var alphaFullStr = 'α:' + alphaStr;
var separator = ' ';
var betaFullStr = 'β:' + betaStr;
// Text zentriert zeichnen: zuerst Gesamtbreite messen
var wAlpha = ctx.measureText(alphaFullStr).width;
var wSep = ctx.measureText(separator).width;
var wBeta = ctx.measureText(betaFullStr).width;
var totalW = wAlpha + wSep + wBeta;
var startX = cx - totalW / 2;
// α in Rot
ctx.textAlign = 'left';
ctx.fillStyle = c.maxAccent;
ctx.fillText(alphaFullStr, startX, textY);
// Separator in Grau
ctx.fillStyle = c.neutralBorder;
ctx.fillText(separator, startX + wAlpha, textY);
// β in Blau
ctx.fillStyle = c.minAccent;
ctx.fillText(betaFullStr, startX + wAlpha + wSep, textY);
}
};
// Export: Global verfügbar für TreeRenderer
if (typeof window !== 'undefined') {
window.NumericNodeRenderer = NumericNodeRenderer;
}
// Export für Node.js/CommonJS
if (typeof module !== 'undefined' && module.exports) {
module.exports = NumericNodeRenderer;
}