Spaces:
Running
Running
| /** | |
| * 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<string>} 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<string>} 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<string>} pathNodeIds | |
| * @returns {Set<string>} 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<string>} pathNodeIds | |
| * @param {Set<string>} 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; | |