viz/tree-viz/engines/numeric-nodes.js

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