Spaces:
Running
Running
| /** | |
| * GraphExport - XuαΊ₯t Δα» thα» dαΊ‘ng αΊ£nh PNG hoαΊ·c SVG | |
| * Cung cαΊ₯p nΓΊt "Export Graph" khi Δα» thα» Δược hiα»n thα», | |
| * cho phΓ©p chα»n Δα»nh dαΊ‘ng PNG hoαΊ·c SVG vΓ tαΊ£i xuα»ng file. | |
| * Requirements: 24.1, 24.2, 24.3, 24.4, 24.5 | |
| */ | |
| const GraphExport = (function () { | |
| class GraphExport { | |
| /** | |
| * @param {string} [containerId='graphExportContainer'] - ID of the container element | |
| */ | |
| constructor(containerId = 'graphExportContainer') { | |
| /** @type {HTMLElement|null} */ | |
| this._container = document.getElementById(containerId); | |
| /** @type {Function|null} */ | |
| this._unsubscribe = null; | |
| /** @type {boolean} */ | |
| this._graphAvailable = false; | |
| this._init(); | |
| } | |
| // βββ Private ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _init() { | |
| if (!this._container) { | |
| console.warn('[GraphExport] Container not found:', 'graphExportContainer'); | |
| return; | |
| } | |
| this._render(); | |
| this._bindEvents(); | |
| // Subscribe to currentModel changes to show/hide export controls | |
| this._unsubscribe = StateManager.subscribe('currentModel', (model) => { | |
| this._graphAvailable = !!model; | |
| this._updateVisibility(); | |
| }); | |
| // Set initial state | |
| this._graphAvailable = !!StateManager.getCurrentModel(); | |
| this._updateVisibility(); | |
| } | |
| /** | |
| * Render the export UI into the container. | |
| */ | |
| _render() { | |
| this._container.innerHTML = ''; | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'graph-export-wrapper'; | |
| // Export Graph button | |
| const exportBtn = document.createElement('button'); | |
| exportBtn.className = 'btn btn-outline-secondary btn-sm'; | |
| exportBtn.id = 'graphExportBtn'; | |
| exportBtn.title = 'Export Graph as Image'; | |
| exportBtn.innerHTML = '<i class="fas fa-image me-1"></i>Export Graph'; | |
| wrapper.appendChild(exportBtn); | |
| // Dropdown for format selection | |
| const dropdown = document.createElement('div'); | |
| dropdown.className = 'graph-export-dropdown'; | |
| dropdown.id = 'graphExportDropdown'; | |
| dropdown.style.display = 'none'; | |
| const pngBtn = document.createElement('button'); | |
| pngBtn.className = 'graph-export-option'; | |
| pngBtn.dataset.format = 'png'; | |
| pngBtn.textContent = 'PNG (Raster)'; | |
| const svgBtn = document.createElement('button'); | |
| svgBtn.className = 'graph-export-option'; | |
| svgBtn.dataset.format = 'svg'; | |
| svgBtn.textContent = 'SVG (Vector)'; | |
| dropdown.appendChild(pngBtn); | |
| dropdown.appendChild(svgBtn); | |
| wrapper.appendChild(dropdown); | |
| this._container.appendChild(wrapper); | |
| } | |
| /** | |
| * Bind click events. | |
| */ | |
| _bindEvents() { | |
| const exportBtn = document.getElementById('graphExportBtn'); | |
| const dropdown = document.getElementById('graphExportDropdown'); | |
| if (exportBtn && dropdown) { | |
| exportBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const isVisible = dropdown.style.display !== 'none'; | |
| dropdown.style.display = isVisible ? 'none' : 'block'; | |
| }); | |
| dropdown.addEventListener('click', (e) => { | |
| const option = e.target.closest('.graph-export-option'); | |
| if (!option) return; | |
| const format = option.dataset.format; | |
| dropdown.style.display = 'none'; | |
| this._exportGraph(format); | |
| }); | |
| // Close dropdown when clicking outside | |
| document.addEventListener('click', () => { | |
| dropdown.style.display = 'none'; | |
| }); | |
| } | |
| } | |
| /** | |
| * Show or hide the export controls based on graph availability. | |
| */ | |
| _updateVisibility() { | |
| if (!this._container) return; | |
| this._container.style.display = this._graphAvailable ? '' : 'none'; | |
| } | |
| /** | |
| * Get the Cytoscape instance from the app's GraphVisualizer. | |
| * @returns {object|null} Cytoscape core instance | |
| */ | |
| _getCytoscapeInstance() { | |
| try { | |
| if (window._onnxApp && typeof window._onnxApp.getGraphVisualizer === 'function') { | |
| const visualizer = window._onnxApp.getGraphVisualizer(); | |
| if (visualizer && visualizer._cy) { | |
| return visualizer._cy; | |
| } | |
| } | |
| } catch (err) { | |
| console.warn('[GraphExport] Could not access Cytoscape instance:', err); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Derive the base file name from the current model. | |
| * @returns {string} | |
| */ | |
| _getBaseFileName() { | |
| const model = StateManager.getCurrentModel(); | |
| if (model && model.metadata && model.metadata.fileName) { | |
| const raw = model.metadata.fileName; | |
| const base = raw.split('/').pop().split('\\').pop(); | |
| return base.replace(/\.[^.]+$/, ''); | |
| } | |
| return 'model'; | |
| } | |
| /** | |
| * Trigger a browser download of a Blob. | |
| * @param {Blob} blob | |
| * @param {string} fileName | |
| */ | |
| _triggerDownload(blob, fileName) { | |
| const url = URL.createObjectURL(blob); | |
| const anchor = document.createElement('a'); | |
| anchor.href = url; | |
| anchor.download = fileName; | |
| anchor.style.display = 'none'; | |
| document.body.appendChild(anchor); | |
| anchor.click(); | |
| document.body.removeChild(anchor); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } | |
| /** | |
| * Show a notification via ErrorDisplay or console. | |
| * @param {string} message | |
| * @param {'success'|'error'} type | |
| */ | |
| _showNotification(message, type) { | |
| if (window.ErrorDisplay && typeof window.ErrorDisplay.show === 'function') { | |
| window.ErrorDisplay.show(message, type === 'error' ? 'error' : 'info'); | |
| } else { | |
| type === 'error' | |
| ? console.error('[GraphExport]', message) | |
| : console.info('[GraphExport]', message); | |
| } | |
| } | |
| /** | |
| * Export the graph in the specified format. | |
| * @param {'png'|'svg'} format | |
| */ | |
| _exportGraph(format) { | |
| try { | |
| const cy = this._getCytoscapeInstance(); | |
| if (!cy) { | |
| this._showNotification('Graph is not available for export.', 'error'); | |
| return; | |
| } | |
| const baseName = this._getBaseFileName(); | |
| if (format === 'png') { | |
| this._exportPNG(cy, baseName); | |
| } else if (format === 'svg') { | |
| this._exportSVG(cy, baseName); | |
| } | |
| } catch (err) { | |
| console.error('[GraphExport] Export failed:', err); | |
| this._showNotification('Failed to export graph image.', 'error'); | |
| } | |
| } | |
| /** | |
| * Export graph as PNG. | |
| * @param {object} cy - Cytoscape instance | |
| * @param {string} baseName | |
| */ | |
| _exportPNG(cy, baseName) { | |
| try { | |
| // Ensure graph has content | |
| if (cy.nodes().length === 0) { | |
| this._showNotification('No graph content to export.', 'error'); | |
| return; | |
| } | |
| const dataUrl = cy.png({ full: true, scale: 2, bg: '#ffffff', maxWidth: 4096, maxHeight: 4096 }); | |
| if (!dataUrl || !dataUrl.includes(',')) { | |
| this._showNotification('Failed to generate PNG image.', 'error'); | |
| return; | |
| } | |
| // Convert data URL to Blob | |
| const parts = dataUrl.split(','); | |
| const byteString = atob(parts[1]); | |
| if (byteString.length === 0) { | |
| this._showNotification('Generated PNG is empty. Try zooming into the graph first.', 'error'); | |
| return; | |
| } | |
| const mimeType = parts[0].split(':')[1].split(';')[0]; | |
| const ab = new ArrayBuffer(byteString.length); | |
| const ia = new Uint8Array(ab); | |
| for (let i = 0; i < byteString.length; i++) { | |
| ia[i] = byteString.charCodeAt(i); | |
| } | |
| const blob = new Blob([ab], { type: mimeType }); | |
| const fileName = baseName + '_graph.png'; | |
| this._triggerDownload(blob, fileName); | |
| this._showNotification('Graph exported as PNG.', 'success'); | |
| } catch (err) { | |
| console.error('[GraphExport] PNG export failed:', err); | |
| this._showNotification('Failed to export PNG: ' + err.message, 'error'); | |
| } | |
| } | |
| /** | |
| * Export graph as SVG by manually building SVG from Cytoscape data. | |
| * Note: cy.svg() requires cytoscape-svg extension which is not loaded, | |
| * so we build SVG manually from node/edge positions. | |
| * @param {object} cy - Cytoscape instance | |
| * @param {string} baseName | |
| */ | |
| _exportSVG(cy, baseName) { | |
| try { | |
| if (cy.nodes().length === 0) { | |
| this._showNotification('No graph content to export.', 'error'); | |
| return; | |
| } | |
| // Try cy.svg() first (if cytoscape-svg extension is loaded) | |
| if (typeof cy.svg === 'function') { | |
| try { | |
| var svgContent = cy.svg({ full: true, scale: 1, bg: '#ffffff' }); | |
| if (svgContent && svgContent.length > 100) { | |
| var blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' }); | |
| var fileName = baseName + '_graph.svg'; | |
| this._triggerDownload(blob, fileName); | |
| this._showNotification('Graph exported as SVG.', 'success'); | |
| return; | |
| } | |
| } catch (_) { | |
| // cy.svg() not available or failed, fall through to manual SVG | |
| } | |
| } | |
| // Fallback: build SVG manually from Cytoscape graph data | |
| var bb = cy.elements().boundingBox(); | |
| var padding = 40; | |
| var svgWidth = Math.ceil(bb.w + padding * 2); | |
| var svgHeight = Math.ceil(bb.h + padding * 2); | |
| var offsetX = -bb.x1 + padding; | |
| var offsetY = -bb.y1 + padding; | |
| var svgParts = []; | |
| svgParts.push('<?xml version="1.0" encoding="UTF-8"?>'); | |
| svgParts.push('<svg xmlns="http://www.w3.org/2000/svg" width="' + svgWidth + '" height="' + svgHeight + '" viewBox="0 0 ' + svgWidth + ' ' + svgHeight + '">'); | |
| svgParts.push('<rect width="100%" height="100%" fill="#ffffff"/>'); | |
| // Draw edges | |
| cy.edges().forEach(function (edge) { | |
| var src = edge.source().position(); | |
| var tgt = edge.target().position(); | |
| var x1 = src.x + offsetX; | |
| var y1 = src.y + offsetY; | |
| var x2 = tgt.x + offsetX; | |
| var y2 = tgt.y + offsetY; | |
| svgParts.push('<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" stroke="#999" stroke-width="1" opacity="0.6"/>'); | |
| }); | |
| // Draw nodes | |
| cy.nodes().forEach(function (node) { | |
| var pos = node.position(); | |
| var x = pos.x + offsetX; | |
| var y = pos.y + offsetY; | |
| var data = node.data(); | |
| var label = data.label || data.name || data.id || ''; | |
| var color = '#87CEEB'; | |
| if (node.hasClass('input-node')) color = '#28a745'; | |
| else if (node.hasClass('output-node')) color = '#dc3545'; | |
| else if (node.hasClass('initializer-node')) color = '#6c757d'; | |
| svgParts.push('<circle cx="' + x + '" cy="' + y + '" r="12" fill="' + color + '" stroke="#333" stroke-width="1"/>'); | |
| // Escape label for SVG | |
| var safeLabel = String(label).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| if (safeLabel.length > 15) safeLabel = safeLabel.substring(0, 15) + 'β¦'; | |
| svgParts.push('<text x="' + x + '" y="' + (y + 22) + '" text-anchor="middle" font-size="8" fill="#333">' + safeLabel + '</text>'); | |
| }); | |
| svgParts.push('</svg>'); | |
| var svgStr = svgParts.join('\n'); | |
| var svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }); | |
| var svgFileName = baseName + '_graph.svg'; | |
| this._triggerDownload(svgBlob, svgFileName); | |
| this._showNotification('Graph exported as SVG.', 'success'); | |
| } catch (err) { | |
| console.error('[GraphExport] SVG export failed:', err); | |
| this._showNotification('Failed to export SVG: ' + err.message, 'error'); | |
| } | |
| } | |
| // βββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Programmatically export the graph (useful for testing). | |
| * @param {'png'|'svg'} format | |
| */ | |
| exportGraph(format) { | |
| this._exportGraph(format); | |
| } | |
| /** | |
| * Clean up subscriptions and DOM. | |
| */ | |
| destroy() { | |
| if (typeof this._unsubscribe === 'function') { | |
| this._unsubscribe(); | |
| this._unsubscribe = null; | |
| } | |
| if (this._container) { | |
| this._container.innerHTML = ''; | |
| } | |
| } | |
| } | |
| return GraphExport; | |
| })(); | |
| // Export as global for browser usage | |
| window.GraphExport = GraphExport; | |