Spaces:
Running
Running
| /** | |
| * 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(`<strong>${this._escapeHtml(data.label || data.id)}</strong>`); | |
| lines.push(`<span style="color:#aaa">Name: ${this._escapeHtml(data.name)}</span>`); | |
| } else { | |
| lines.push(`<strong>${this._escapeHtml(data.label || data.id)}</strong>`); | |
| } | |
| // Op type | |
| if (data.opType) { | |
| lines.push(`<span>Type: <em>${this._escapeHtml(data.opType)}</em></span>`); | |
| } | |
| // Shape (for input/output/initializer) | |
| if (data.shape && data.shape.length > 0) { | |
| lines.push(`<span>Shape: [${data.shape.join(', ')}]</span>`); | |
| } | |
| // Data type | |
| if (data.dataType) { | |
| lines.push(`<span>DType: ${this._escapeHtml(String(data.dataType))}</span>`); | |
| } | |
| // Attributes (for op nodes) | |
| if (data.attributes && typeof data.attributes === 'object') { | |
| const attrKeys = Object.keys(data.attributes); | |
| if (attrKeys.length > 0) { | |
| lines.push('<span style="color:#aaa;margin-top:4px;display:block">Attributes:</span>'); | |
| attrKeys.slice(0, 6).forEach((key) => { | |
| const val = data.attributes[key]; | |
| const valStr = this._formatAttrValue(val); | |
| lines.push(`<span style="padding-left:8px">${this._escapeHtml(key)}: ${valStr}</span>`); | |
| }); | |
| if (attrKeys.length > 6) { | |
| lines.push(`<span style="color:#aaa;padding-left:8px">...+${attrKeys.length - 6} more</span>`); | |
| } | |
| } | |
| } | |
| // Cluster info | |
| if (data.isCluster && data.nodeCount) { | |
| lines.push(`<span style="color:#aaa">${data.nodeCount} nodes</span>`); | |
| } | |
| return lines.join('<br>'); | |
| } | |
| /** | |
| * Format an attribute value for display. | |
| * @param {*} val | |
| * @returns {string} | |
| * @private | |
| */ | |
| _formatAttrValue(val) { | |
| if (val === null || val === undefined) return '<em>null</em>'; | |
| 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, '>') | |
| .replace(/"/g, '"'); | |
| } | |
| } | |
| // Export as global for browser usage | |
| window.GraphVisualizer = GraphVisualizer; | |