viz/tree-viz/engines/interaction-engine.js

/**
 * @fileoverview TreeInteractionEngine - Modul für Benutzerinteraktion und Viewport-Management
 * 
 * Implementiert alle Interaktionsmechaniken:
 * - setupWheelZoom(): Maus-Wheel Zooming mit Smart-Pause für Active-Node-Tracking
 * - setupDragPan(): Drag-basiertes Pannen des Baums
 * - setupNodeClickDetection(): Klick-Erkennung auf Baum-Knoten
 * - setupTouchSupport(): Touch-Gesten (Pinch-to-Zoom, Drag)
 * - getViewportTransform(): Berechnung der Viewport-Transformationen
 * 
 * @author Alexander Wolf
 * @version 1.0
 */
var TreeInteractionEngine = {
    /**
     * Setup: Maus-Wheel Zoom (mit Pause für Active Node Tracking)
     * @param {HTMLCanvasElement} canvas
     * @param {Object} viewport - { scale, offsetX, offsetY, minScale, maxScale }
     * @param {Object} activeNodeTracking - { paused } flag
     * @param {Function} onViewportChange - Callback nach Änderung
     */
    setupWheelZoom(canvas, viewport, activeNodeTracking, onViewportChange) {
        canvas.addEventListener('wheel', (e) => {
            e.preventDefault();
            // Pause active node tracking during zoom
            if (activeNodeTracking) {
                activeNodeTracking.paused = true;
                // Resume after zoom ends
                setTimeout(() => { activeNodeTracking.paused = false; }, 300);
            }
            
            const rect = canvas.getBoundingClientRect();
            const mouseX = e.clientX - rect.left;
            const mouseY = e.clientY - rect.top;

            const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
            const newScale = Math.max(
                viewport.minScale,
                Math.min(viewport.maxScale, viewport.scale * zoomFactor)
            );

            const scaleRatio = newScale / viewport.scale;
            viewport.offsetX = mouseX - (mouseX - viewport.offsetX) * scaleRatio;
            viewport.offsetY = mouseY - (mouseY - viewport.offsetY) * scaleRatio;
            viewport.scale = newScale;

            onViewportChange();
        });
    },

    /**
     * Setup: Maus-Drag Pan (mit Pause für Active Node Tracking)
     * @param {HTMLCanvasElement} canvas
     * @param {Object} viewport - { offsetX, offsetY }
     * @param {Object} activeNodeTracking - { paused } flag
     * @param {Function} onViewportChange - Callback nach Änderung
     */
    setupMouseDrag(canvas, viewport, activeNodeTracking, onViewportChange) {
        let isDragging = false;
        let dragStartX = 0, dragStartY = 0;

        canvas.addEventListener('mousedown', (e) => {
            isDragging = true;
            // Pause active node tracking during pan
            if (activeNodeTracking) {
                activeNodeTracking.paused = true;
            }
            const rect = canvas.getBoundingClientRect();
            dragStartX = e.clientX - rect.left - viewport.offsetX;
            dragStartY = e.clientY - rect.top - viewport.offsetY;
            canvas.style.cursor = 'grabbing';
        });

        canvas.addEventListener('mousemove', (e) => {
            if (isDragging) {
                const rect = canvas.getBoundingClientRect();
                const mouseX = e.clientX - rect.left;
                const mouseY = e.clientY - rect.top;
                viewport.offsetX = mouseX - dragStartX;
                viewport.offsetY = mouseY - dragStartY;
                onViewportChange();
            }
        });

        canvas.addEventListener('mouseup', () => {
            isDragging = false;
            // Resume active node tracking
            if (activeNodeTracking) {
                activeNodeTracking.paused = false;
            }
            canvas.style.cursor = 'grab';
        });

        canvas.addEventListener('mouseleave', () => {
            isDragging = false;
            // Resume active node tracking
            if (activeNodeTracking) {
                activeNodeTracking.paused = false;
            }
            canvas.style.cursor = 'default';
        });

        canvas.style.cursor = 'grab';
    },

    /**
     * Setup: Click Detection (für Node-Auswahl)
     * @param {HTMLCanvasElement} canvas
     * @param {Function} getNodeAtPoint - Funktion zur Hit-Detection
     * @param {Function} onNodeClicked - Callback(node)
     */
    setupNodeClick(canvas, getNodeAtPoint, onNodeClicked) {
        canvas.addEventListener('click', (e) => {
            const rect = canvas.getBoundingClientRect();
            const canvasX = e.clientX - rect.left;
            const canvasY = e.clientY - rect.top;

            const clickedNode = getNodeAtPoint(canvasX, canvasY);
            if (clickedNode) {
                if (onNodeClicked) onNodeClicked(clickedNode);
            }
        });
    },

    /**
     * Setup: Touch Pinch Zoom + Pan (mit Pause für Active Node Tracking)
     * @param {HTMLCanvasElement} canvas
     * @param {Object} viewport
     * @param {Object} activeNodeTracking - { paused } flag
     * @param {Function} onViewportChange
     */
    setupTouchGestures(canvas, viewport, activeNodeTracking, onViewportChange) {
        let lastTouchDistance = 0;

        canvas.addEventListener('touchmove', (e) => {
            // Pause active node tracking during touch
            if (activeNodeTracking) {
                activeNodeTracking.paused = true;
            }
            
            if (e.touches.length === 2) {
                // Pinch Zoom
                const touch1 = e.touches[0];
                const touch2 = e.touches[1];
                const dx = touch2.clientX - touch1.clientX;
                const dy = touch2.clientY - touch1.clientY;
                const distance = Math.sqrt(dx * dx + dy * dy);

                if (lastTouchDistance > 0) {
                    const zoomFactor = distance / lastTouchDistance;
                    const newScale = Math.max(
                        viewport.minScale,
                        Math.min(viewport.maxScale, viewport.scale * zoomFactor)
                    );
                    viewport.scale = newScale;
                    onViewportChange();
                }
                lastTouchDistance = distance;
            } else if (e.touches.length === 1) {
                // Single finger pan
                const touch = e.touches[0];
                const rect = canvas.getBoundingClientRect();
                const touchX = touch.clientX - rect.left;
                const touchY = touch.clientY - rect.top;
                // Pan logic would go here
            }
        });

        canvas.addEventListener('touchend', () => {
            lastTouchDistance = 0;
            // Resume active node tracking
            if (activeNodeTracking) {
                activeNodeTracking.paused = false;
            }
        });
    },

    /**
     * Hit Detection: Finde Node bei Canvas-Koordinaten
     * @param {number} canvasX
     * @param {number} canvasY
     * @param {Map} nodes
     * @param {Object} viewport
     * @param {Object} config - TreeVizEngine config (optional, for enableTreeExpansion check)
     * @returns {Object|null} Node oder null
     */
    getNodeAtCanvasPoint(canvasX, canvasY, nodes, viewport, config) {
        // Transformiere zu World-Koordinaten
        const treeX = (canvasX - viewport.offsetX) / viewport.scale;
        const treeY = (canvasY - viewport.offsetY) / viewport.scale;

        // Prüfe alle Knoten auf Hit (in reverse order - zuletzt gerenderte zuerst)
        const nodeArray = Array.from(nodes.values());
        for (let i = nodeArray.length - 1; i >= 0; i--) {
            const node = nodeArray[i];
            
            // Prüfe zuerst Expansion-Symbol (skip only if enableTreeExpansion === false)
            if (NodeExpansionEngine && NodeExpansionEngine.isClickOnExpansionIndicator(node, treeX, treeY)) {
                // Skip only if explicitly disabled
                if (!config || config.enableTreeExpansion !== false) {
                    node._hitExpansionIndicator = true;
                    return node;
                }
            }
            
            // Dann prüfe Node selbst
            const dx = treeX - node.x;
            const dy = treeY - node.y;
            const distance = Math.sqrt(dx * dx + dy * dy);
            const radius = node.radius || 15;

            if (distance <= radius) {
                node._hitExpansionIndicator = false;
                return node;
            }
        }

        return null;
    }
};

// Make globally available
if (typeof window !== 'undefined') {
    window.TreeInteractionEngine = TreeInteractionEngine;
}

// Export for Node.js/CommonJS
if (typeof module !== 'undefined' && module.exports) {
    module.exports = TreeInteractionEngine;
}