/** * GraphPathHighlighter - Highlight đường đi trong đồ thị * Tìm và highlight upstream path (từ input đến node) và downstream path (từ node đến output). * Làm mờ các node/edge không thuộc đường đi. Xóa highlight khi click vào vùng trống. * Requirements: 18.1, 18.2, 18.3, 18.4, 18.5 */ class GraphPathHighlighter { constructor() { /** @type {boolean} Whether a path is currently highlighted */ this._isActive = false; /** @type {string|null} The node ID being traced */ this._tracedNodeId = null; /** @type {Function|null} EventBus unsubscribe for path:highlight-requested */ this._unsubHighlight = null; /** @type {Function|null} EventBus unsubscribe for path:cleared */ this._unsubCleared = null; /** @type {Function|null} EventBus unsubscribe for model:loaded */ this._unsubModelLoaded = null; this._bindEvents(); } // ─── Public API ──────────────────────────────────────────────────────────── /** * Highlight the full path (upstream + downstream) through a node. * @param {cytoscape.Core} cy - Cytoscape instance * @param {string} nodeId - The node to trace paths through */ highlightPath(cy, nodeId) { if (!cy || !nodeId) return; const node = cy.getElementById(nodeId); if (!node || node.length === 0) { console.warn('[GraphPathHighlighter] Node not found:', nodeId); return; } // Find upstream and downstream paths const upstreamIds = this._findUpstream(cy, nodeId); const downstreamIds = this._findDownstream(cy, nodeId); console.log('[GraphPathHighlighter] upstream:', upstreamIds.size, 'downstream:', downstreamIds.size, 'total nodes:', cy.nodes().length); // Combine all path node IDs (include the selected node itself) const pathNodeIds = new Set([...upstreamIds, nodeId, ...downstreamIds]); // If no path found at all (isolated node with no connections) if (pathNodeIds.size === 1 && upstreamIds.size === 0 && downstreamIds.size === 0) { this._showMessage('Không tìm thấy đường đi nào qua node này.'); return; } // Collect edges on the path const pathEdgeIds = this._findPathEdges(cy, pathNodeIds); // Set traced node BEFORE applying highlight so _applyHighlight can use it this._isActive = true; this._tracedNodeId = nodeId; // Apply highlight classes this._applyHighlight(cy, pathNodeIds, pathEdgeIds); // Emit event if (typeof EventBus !== 'undefined') { EventBus.emit('path:highlighted', { nodeId: nodeId, upstreamCount: upstreamIds.size, downstreamCount: downstreamIds.size }); } } /** * Clear all path highlighting and restore normal graph appearance. * @param {cytoscape.Core} cy - Cytoscape instance */ clearPath(cy) { if (!cy) return; cy.startBatch(); cy.elements().removeClass('path-highlighted path-dimmed path-source'); cy.elements().removeStyle('opacity'); cy.endBatch(); this._isActive = false; this._tracedNodeId = null; if (typeof EventBus !== 'undefined') { EventBus.emit('path:cleared', {}); } } /** * Check if path highlighting is currently active. * @returns {boolean} */ isActive() { return this._isActive; } /** * Get the currently traced node ID. * @returns {string|null} */ getTracedNodeId() { return this._tracedNodeId; } /** * Destroy and clean up all resources. */ destroy() { if (this._unsubHighlight) { this._unsubHighlight(); this._unsubHighlight = null; } if (this._unsubCleared) { this._unsubCleared(); this._unsubCleared = null; } if (this._unsubModelLoaded) { this._unsubModelLoaded(); this._unsubModelLoaded = null; } this._isActive = false; this._tracedNodeId = null; } // ─── Path Finding ────────────────────────────────────────────────────────── /** * Find all upstream (ancestor) nodes via BFS backwards from the given node. * Traverses incoming edges to find all predecessors up to input nodes. * @param {cytoscape.Core} cy * @param {string} nodeId * @returns {Set} Set of upstream node IDs * @private */ _findUpstream(cy, nodeId) { const visited = new Set(); const queue = [nodeId]; while (queue.length > 0) { const currentId = queue.shift(); const current = cy.getElementById(currentId); if (!current || current.length === 0) continue; // Get all incoming edges (edges where this node is the target) const incomers = current.incomers('node'); incomers.forEach(function (predecessor) { const predId = predecessor.id(); if (!visited.has(predId)) { visited.add(predId); queue.push(predId); } }); } return visited; } /** * Find all downstream (descendant) nodes via BFS forward from the given node. * Traverses outgoing edges to find all successors down to output nodes. * @param {cytoscape.Core} cy * @param {string} nodeId * @returns {Set} Set of downstream node IDs * @private */ _findDownstream(cy, nodeId) { const visited = new Set(); const queue = [nodeId]; while (queue.length > 0) { const currentId = queue.shift(); const current = cy.getElementById(currentId); if (!current || current.length === 0) continue; // Get all outgoing edges (edges where this node is the source) const outgoers = current.outgoers('node'); outgoers.forEach(function (successor) { const sucId = successor.id(); if (!visited.has(sucId)) { visited.add(sucId); queue.push(sucId); } }); } return visited; } /** * Find all edges that connect nodes within the path set. * @param {cytoscape.Core} cy * @param {Set} pathNodeIds * @returns {Set} Set of edge IDs on the path * @private */ _findPathEdges(cy, pathNodeIds) { const pathEdgeIds = new Set(); cy.edges().forEach(function (edge) { var srcId = edge.source().id(); var tgtId = edge.target().id(); if (pathNodeIds.has(srcId) && pathNodeIds.has(tgtId)) { pathEdgeIds.add(edge.id()); } }); return pathEdgeIds; } // ─── Highlight Application ───────────────────────────────────────────────── /** * Apply visual highlight to path elements and dim non-path elements. * @param {cytoscape.Core} cy * @param {Set} pathNodeIds * @param {Set} pathEdgeIds * @private */ _applyHighlight(cy, pathNodeIds, pathEdgeIds) { // First clear any existing path highlight cy.elements().removeClass('path-highlighted path-dimmed path-source'); cy.elements().removeStyle('opacity'); // Batch style changes for performance cy.startBatch(); // Dim ALL elements first using inline style (more reliable than class) cy.elements().style('opacity', 0.15); // Un-dim and highlight path nodes pathNodeIds.forEach(function (id) { var el = cy.getElementById(id); if (el && el.length > 0) { el.style('opacity', 1); el.addClass('path-highlighted'); } }); // Un-dim and highlight path edges pathEdgeIds.forEach(function (id) { var el = cy.getElementById(id); if (el && el.length > 0) { el.style('opacity', 1); el.addClass('path-highlighted'); } }); // Mark the source node distinctly var sourceId = this._tracedNodeId; if (sourceId) { var sourceNode = cy.getElementById(sourceId); if (sourceNode && sourceNode.length > 0) { sourceNode.addClass('path-source'); } } cy.endBatch(); } // ─── Event Handling ──────────────────────────────────────────────────────── /** * Bind EventBus listeners for path highlight requests and clearing. * @private */ _bindEvents() { if (typeof EventBus === 'undefined' || typeof CONFIG === 'undefined') return; // Listen for path highlight requests (Shift+click or Trace Path button) this._unsubHighlight = EventBus.on('path:highlight-requested', (data) => { console.log('[GraphPathHighlighter] path:highlight-requested received:', data); if (!data || !data.nodeId) return; var cy = this._getCytoscapeInstance(); if (!cy) { console.warn('[GraphPathHighlighter] No Cytoscape instance available'); return; } console.log('[GraphPathHighlighter] Highlighting path for node:', data.nodeId); this.highlightPath(cy, data.nodeId); if (cy) { this.highlightPath(cy, data.nodeId); } }); // Listen for path clear requests this._unsubCleared = EventBus.on('path:clear-requested', () => { var cy = this._getCytoscapeInstance(); if (cy) { this.clearPath(cy); } }); // Clear path when a new model is loaded this._unsubModelLoaded = EventBus.on(CONFIG.EVENTS.MODEL_LOADED, () => { var cy = this._getCytoscapeInstance(); if (cy) { this.clearPath(cy); } }); } /** * Attach Shift+click and background-click handlers to a Cytoscape instance. * Call this after the graph is initialized/rendered. * @param {cytoscape.Core} cy */ attachCytoscapeHandlers(cy) { if (!cy) return; // Remove previous handlers to avoid duplicates cy.off('tap.pathHighlighter'); // Shift+click on a node → trace path cy.on('tap.pathHighlighter', 'node', (evt) => { if (evt.originalEvent && evt.originalEvent.shiftKey) { var nodeId = evt.target.id(); this.highlightPath(cy, nodeId); } }); // Click on background → clear path cy.on('tap.pathHighlighter', (evt) => { if (evt.target === cy && this._isActive) { this.clearPath(cy); } }); } // ─── Helpers ─────────────────────────────────────────────────────────────── /** * Get the Cytoscape instance from the global app interface. * @returns {cytoscape.Core|null} * @private */ _getCytoscapeInstance() { if (window._onnxApp && typeof window._onnxApp.getGraphVisualizer === 'function') { var gv = window._onnxApp.getGraphVisualizer(); if (gv && gv._cy) { return gv._cy; } } return null; } /** * Show a temporary message to the user (e.g., "no path found"). * @param {string} message * @private */ _showMessage(message) { // Use EventBus error display if available if (typeof EventBus !== 'undefined' && typeof CONFIG !== 'undefined') { EventBus.emit(CONFIG.EVENTS.ERROR_OCCURRED, { message: message, type: 'info' }); return; } console.info('[GraphPathHighlighter]', message); } } // Export as global for browser usage window.GraphPathHighlighter = GraphPathHighlighter;