/** * GraphVisualizer - Bộ Trực Quan Hóa Đồ Thị * Hiển thị đồ thị tính toán ONNX bằng Cytoscape.js * Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6 */ class GraphVisualizer { constructor() { /** @type {cytoscape.Core|null} */ this._cy = null; /** @type {string|null} */ this._selectedNodeId = null; /** @type {string|null} */ this._highlightedNodeId = null; /** @type {Function|null} Unsubscribe from StateManager */ this._unsubscribeState = null; /** @type {HTMLElement|null} */ this._container = null; /** @type {HTMLElement|null} Tooltip element */ this._tooltip = null; } // ─── Public API ──────────────────────────────────────────────────────────── /** * Khởi tạo trực quan hóa đồ thị trong container element. * @param {HTMLElement} containerElement * @param {Array} graphData - Cytoscape elements array từ GraphProcessor */ initialize(containerElement, graphData) { if (!containerElement) { console.error('[GraphVisualizer] containerElement is required'); return; } this._container = containerElement; // Ensure container has an id if (!containerElement.id) { containerElement.id = 'graph-visualizer-' + Date.now(); } // Destroy existing instance if any if (this._cy) { this._cy.destroy(); this._cy = null; } // Remove old tooltip this._removeTooltip(); // Create tooltip element this._createTooltip(); // Build cytoscape stylesheet const stylesheet = this._buildStylesheet(); // Determine layout const layout = this._buildLayout(); // Initialize Cytoscape this._cy = cytoscape({ container: containerElement, elements: graphData || [], style: stylesheet, layout: layout, minZoom: CONFIG.GRAPH.MIN_ZOOM, maxZoom: CONFIG.GRAPH.MAX_ZOOM, zoom: CONFIG.GRAPH.DEFAULT_ZOOM, wheelSensitivity: 0.3, boxSelectionEnabled: false, autounselectify: false }); // Bind user interactions this._bindEvents(); // Subscribe to StateManager selectedNodeId changes this._subscribeToState(); console.log('[GraphVisualizer] Initialized with', (graphData || []).length, 'elements'); } /** * Cập nhật đồ thị với dữ liệu mới. * @param {Array} graphData - Cytoscape elements array */ updateGraph(graphData) { if (!this._cy) { console.warn('[GraphVisualizer] Not initialized. Call initialize() first.'); return; } // Use requestAnimationFrame for smooth rendering requestAnimationFrame(() => { this._cy.elements().remove(); this._cy.add(graphData || []); const layout = this._buildLayout(); const layoutInstance = this._cy.layout(layout); layoutInstance.run(); this._selectedNodeId = null; this._highlightedNodeId = null; }); } /** * Làm nổi bật một nút theo id. * @param {string} nodeId */ highlightNode(nodeId) { if (!this._cy) return; // Clear previous highlight this.clearHighlight(); const node = this._cy.getElementById(nodeId); if (node && node.length > 0) { node.addClass('highlighted'); this._highlightedNodeId = nodeId; // Pan to the node this._cy.animate({ center: { eles: node }, duration: 300 }); } } /** * Xóa làm nổi bật tất cả các nút. */ clearHighlight() { if (!this._cy) return; this._cy.elements().removeClass('highlighted'); this._highlightedNodeId = null; } /** * Đặt mức phóng to. * @param {number} level */ zoom(level) { if (!this._cy) return; const clamped = Math.min( Math.max(level, CONFIG.GRAPH.MIN_ZOOM), CONFIG.GRAPH.MAX_ZOOM ); this._cy.zoom(clamped); this._cy.center(); if (typeof StateManager !== 'undefined') { StateManager.setZoomLevel(clamped); } } /** * Phóng to thêm một bước. */ zoomIn() { if (!this._cy) return; this.zoom(this._cy.zoom() + CONFIG.GRAPH.ZOOM_STEP); } /** * Thu nhỏ một bước. */ zoomOut() { if (!this._cy) return; this.zoom(this._cy.zoom() - CONFIG.GRAPH.ZOOM_STEP); } /** * Fit đồ thị vào container. */ fit() { if (!this._cy) return; this._cy.fit(); if (typeof StateManager !== 'undefined') { StateManager.setZoomLevel(this._cy.zoom()); } } /** * Reset về zoom mặc định và vị trí trung tâm. */ reset() { if (!this._cy) return; this._cy.zoom(CONFIG.GRAPH.DEFAULT_ZOOM); this._cy.center(); if (typeof StateManager !== 'undefined') { StateManager.setZoomLevel(CONFIG.GRAPH.DEFAULT_ZOOM); } } /** * Lấy nút đang được chọn. * @returns {Object|null} Node data object hoặc null */ getSelectedNode() { if (!this._cy || !this._selectedNodeId) return null; const node = this._cy.getElementById(this._selectedNodeId); if (node && node.length > 0) { return node.data(); } return null; } /** * Hủy và dọn dẹp tài nguyên. */ destroy() { if (this._unsubscribeState) { this._unsubscribeState(); this._unsubscribeState = null; } this._removeTooltip(); if (this._cy) { this._cy.destroy(); this._cy = null; } this._container = null; this._selectedNodeId = null; this._highlightedNodeId = null; } // ─── Private Helpers ─────────────────────────────────────────────────────── /** * Xây dựng stylesheet cho Cytoscape. * @private */ _buildStylesheet() { return [ // ── Base node style ────────────────────────────────────────────── { selector: 'node', style: { 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': '10px', 'color': '#fff', 'text-outline-width': 1, 'text-outline-color': '#555', 'width': 'label', 'height': 'label', 'padding': '8px', 'shape': 'roundrectangle', 'background-color': CONFIG.GRAPH.NODE_DEFAULT_COLOR, 'border-width': 1, 'border-color': '#5a9ab5', 'cursor': 'pointer', 'transition-property': 'background-color, border-color, border-width', 'transition-duration': '0.15s' } }, // ── Input node ─────────────────────────────────────────────────── { selector: '.input-node', style: { 'background-color': '#28a745', 'border-color': '#1e7e34', 'shape': 'ellipse' } }, // ── Output node ────────────────────────────────────────────────── { selector: '.output-node', style: { 'background-color': '#dc3545', 'border-color': '#bd2130', 'shape': 'ellipse' } }, // ── Op node ────────────────────────────────────────────────────── { selector: '.op-node', style: { 'background-color': CONFIG.GRAPH.NODE_DEFAULT_COLOR, 'border-color': '#5a9ab5' } }, // ── Initializer node ───────────────────────────────────────────── { selector: '.initializer-node', style: { 'background-color': '#6c757d', 'border-color': '#545b62', 'shape': 'diamond', 'font-size': '9px' } }, // ── Cluster node ───────────────────────────────────────────────── { selector: '.cluster-node', style: { 'background-color': '#6f42c1', 'border-color': '#5a32a3', 'shape': 'roundrectangle' } }, // ── Highlighted node ───────────────────────────────────────────── { selector: '.highlighted', style: { 'background-color': CONFIG.GRAPH.NODE_HIGHLIGHT_COLOR, 'border-color': '#e6b800', 'border-width': 3, 'color': '#333', 'text-outline-color': '#fff', 'z-index': 999 } }, // ── Selected node ──────────────────────────────────────────────── { selector: 'node:selected', style: { 'border-width': 3, 'border-color': '#0d6efd', 'background-color': CONFIG.GRAPH.NODE_HIGHLIGHT_COLOR, 'color': '#333', 'text-outline-color': '#fff' } }, // ── Edge ───────────────────────────────────────────────────────── { selector: 'edge', style: { 'width': 1.5, 'line-color': CONFIG.GRAPH.EDGE_DEFAULT_COLOR, 'target-arrow-color': CONFIG.GRAPH.EDGE_DEFAULT_COLOR, 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'arrow-scale': 0.8, 'opacity': 0.8 } }, // ── Edge label ─────────────────────────────────────────────────── { selector: 'edge[label]', style: { 'label': 'data(label)', 'font-size': '8px', 'color': '#666', 'text-rotation': 'autorotate', 'text-margin-y': -6 } }, // ── Path highlighted (node/edge on traced path) ───────────────── { selector: '.path-highlighted', style: { 'opacity': 1, 'z-index': 900 } }, { selector: 'node.path-highlighted', style: { 'border-width': 3, 'border-color': '#0d6efd', 'background-color': '#4dabf7' } }, { selector: 'edge.path-highlighted', style: { 'line-color': '#0d6efd', 'target-arrow-color': '#0d6efd', 'width': 2.5, 'opacity': 1 } }, // ── Path dimmed (elements NOT on traced path) ─────────────────── { selector: '.path-dimmed', style: { 'opacity': 0.15 } }, // ── Path source (the node the trace started from) ─────────────── { selector: '.path-source', style: { 'border-width': 4, 'border-color': '#ff6b6b', 'background-color': '#ffa94d', 'z-index': 999 } }, // ── Search highlighted node ────────────────────────────────────── { selector: '.search-highlighted', style: { 'background-color': '#ff6b6b', 'border-color': '#e03131', 'border-width': 3, 'z-index': 998 } }, // ── Current search result (active) ────────────────────────────── { selector: '.search-active', style: { 'background-color': '#ff922b', 'border-color': '#e8590c', 'border-width': 4, 'z-index': 999 } }, // ── Compound group parent node ────────────────────────────────── { selector: '.group-parent, .group-node', style: { 'background-color': 'rgba(108, 117, 125, 0.12)', 'background-opacity': 0.12, 'border-width': 2, 'border-color': '#6c757d', 'border-style': 'dashed', 'shape': 'roundrectangle', 'padding': '16px', 'text-valign': 'top', 'text-halign': 'center', 'font-size': '12px', 'font-weight': 'bold', 'color': '#495057', 'text-outline-width': 0, 'label': 'data(label)', 'min-width': '80px', 'min-height': '40px' } }, // ── Collapsed compound group ──────────────────────────────────── { selector: '.group-parent.collapsed', style: { 'background-color': 'rgba(13, 110, 253, 0.15)', 'background-opacity': 0.15, 'border-color': '#0d6efd', 'border-style': 'solid', 'padding': '12px', 'min-width': '100px', 'min-height': '36px', 'text-valign': 'center', 'text-halign': 'center', 'color': '#0d6efd' } }, // ── Node with annotation badge ────────────────────────────────── { selector: '.has-annotation', style: { 'border-width': 3, 'border-color': '#fd7e14', 'border-style': 'double' } } ]; } /** * Xây dựng cấu hình layout. * @private */ _buildLayout() { // Try dagre first (requires cytoscape-dagre plugin), fallback to breadthfirst const hasDagre = typeof cytoscapeDagre !== 'undefined' || (cytoscape && cytoscape('layout', 'dagre')); return { name: 'breadthfirst', directed: true, padding: 20, spacingFactor: 1.2, avoidOverlap: true, nodeDimensionsIncludeLabels: true, animate: false }; } /** * Bind Cytoscape event handlers. * @private */ _bindEvents() { if (!this._cy) return; // Node click this._cy.on('tap', 'node', (evt) => { // Skip normal node click when Shift is held (path highlighter handles it) if (evt.originalEvent && evt.originalEvent.shiftKey) return; const node = evt.target; const nodeData = node.data(); this._onNodeClick(nodeData, evt); }); // Background click → deselect this._cy.on('tap', (evt) => { if (evt.target === this._cy) { this._onBackgroundClick(); } }); // Node mouseover → show tooltip this._cy.on('mouseover', 'node', (evt) => { const node = evt.target; this._showTooltip(node, evt.originalEvent); }); // Node mouseout → hide tooltip this._cy.on('mouseout', 'node', () => { this._hideTooltip(); }); // Zoom change → update StateManager this._cy.on('zoom', () => { if (typeof StateManager !== 'undefined') { StateManager.setZoomLevel(this._cy.zoom()); } // Persist zoom preference to localStorage try { const key = (typeof CONFIG !== 'undefined' && CONFIG.STORAGE) ? CONFIG.STORAGE.USER_PREFERENCES : 'onnx_explorer_preferences'; const existing = JSON.parse(localStorage.getItem(key) || '{}'); existing.zoomLevel = this._cy.zoom(); localStorage.setItem(key, JSON.stringify(existing)); } catch (_) { /* ignore */ } }); } /** * Handle node click. * @private */ _onNodeClick(nodeData, evt) { this._selectedNodeId = nodeData.id; // Update StateManager if (typeof StateManager !== 'undefined') { StateManager.setSelectedNodeId(nodeData.id); } // Emit NODE_SELECTED event via EventBus if (typeof EventBus !== 'undefined') { EventBus.emit(CONFIG.EVENTS.NODE_SELECTED, { nodeId: nodeData.id, nodeData }); } // Highlight the clicked node this.clearHighlight(); const node = this._cy.getElementById(nodeData.id); if (node && node.length > 0) { node.addClass('highlighted'); this._highlightedNodeId = nodeData.id; } // Show node details in tooltip (positioned near click) if (evt && evt.originalEvent) { this._showTooltip(this._cy.getElementById(nodeData.id), evt.originalEvent); } } /** * Handle background click. * @private */ _onBackgroundClick() { this._selectedNodeId = null; this.clearHighlight(); this._hideTooltip(); if (typeof StateManager !== 'undefined') { StateManager.setSelectedNodeId(null); } // Clear path highlighting if active if (typeof EventBus !== 'undefined') { EventBus.emit('path:clear-requested', {}); } } /** * Subscribe to StateManager selectedNodeId changes. * @private */ _subscribeToState() { if (typeof StateManager === 'undefined') return; this._unsubscribeState = StateManager.subscribe('selectedNodeId', (newNodeId) => { if (newNodeId && newNodeId !== this._highlightedNodeId) { this.highlightNode(newNodeId); } else if (!newNodeId) { this.clearHighlight(); } }); } /** * Create tooltip DOM element. * @private */ _createTooltip() { const tooltip = document.createElement('div'); tooltip.className = 'graph-node-tooltip'; tooltip.style.cssText = [ 'position: fixed', 'z-index: 9999', 'background: rgba(30,30,30,0.95)', 'color: #fff', 'padding: 8px 12px', 'border-radius: 6px', 'font-size: 12px', 'max-width: 280px', 'pointer-events: none', 'display: none', 'box-shadow: 0 2px 8px rgba(0,0,0,0.4)', 'line-height: 1.5' ].join(';'); document.body.appendChild(tooltip); this._tooltip = tooltip; } /** * Remove tooltip from DOM. * @private */ _removeTooltip() { if (this._tooltip && this._tooltip.parentNode) { this._tooltip.parentNode.removeChild(this._tooltip); } this._tooltip = null; } /** * Show tooltip for a node. * @param {cytoscape.NodeSingular} node * @param {MouseEvent} mouseEvent * @private */ _showTooltip(node, mouseEvent) { if (!this._tooltip || !node || node.length === 0) return; const data = node.data(); const html = this._buildTooltipHTML(data); this._tooltip.innerHTML = html; this._tooltip.style.display = 'block'; this._positionTooltip(mouseEvent); } /** * Hide tooltip. * @private */ _hideTooltip() { if (this._tooltip) { this._tooltip.style.display = 'none'; } } /** * Position tooltip near the mouse cursor. * @param {MouseEvent} mouseEvent * @private */ _positionTooltip(mouseEvent) { if (!this._tooltip || !mouseEvent) return; const offset = 12; let x = mouseEvent.clientX + offset; let y = mouseEvent.clientY + offset; // Keep within viewport const rect = this._tooltip.getBoundingClientRect(); if (x + rect.width > window.innerWidth) { x = mouseEvent.clientX - rect.width - offset; } if (y + rect.height > window.innerHeight) { y = mouseEvent.clientY - rect.height - offset; } this._tooltip.style.left = x + 'px'; this._tooltip.style.top = y + 'px'; } /** * Build tooltip HTML content for a node. * @param {Object} data - Node data * @returns {string} * @private */ _buildTooltipHTML(data) { const lines = []; // Name / label if (data.name && data.name !== data.label) { lines.push(`${this._escapeHtml(data.label || data.id)}`); lines.push(`Name: ${this._escapeHtml(data.name)}`); } else { lines.push(`${this._escapeHtml(data.label || data.id)}`); } // Op type if (data.opType) { lines.push(`Type: ${this._escapeHtml(data.opType)}`); } // Shape (for input/output/initializer) if (data.shape && data.shape.length > 0) { lines.push(`Shape: [${data.shape.join(', ')}]`); } // Data type if (data.dataType) { lines.push(`DType: ${this._escapeHtml(String(data.dataType))}`); } // Attributes (for op nodes) if (data.attributes && typeof data.attributes === 'object') { const attrKeys = Object.keys(data.attributes); if (attrKeys.length > 0) { lines.push('Attributes:'); attrKeys.slice(0, 6).forEach((key) => { const val = data.attributes[key]; const valStr = this._formatAttrValue(val); lines.push(`${this._escapeHtml(key)}: ${valStr}`); }); if (attrKeys.length > 6) { lines.push(`...+${attrKeys.length - 6} more`); } } } // Cluster info if (data.isCluster && data.nodeCount) { lines.push(`${data.nodeCount} nodes`); } return lines.join('
'); } /** * Format an attribute value for display. * @param {*} val * @returns {string} * @private */ _formatAttrValue(val) { if (val === null || val === undefined) return 'null'; if (Array.isArray(val)) { const preview = val.slice(0, 4).join(', '); return `[${this._escapeHtml(preview)}${val.length > 4 ? '...' : ''}]`; } const str = String(val); return this._escapeHtml(str.length > 40 ? str.slice(0, 40) + '…' : str); } /** * Escape HTML special characters. * @param {string} str * @returns {string} * @private */ _escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } } // Export as global for browser usage window.GraphVisualizer = GraphVisualizer;