/** * 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 = '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(''); svgParts.push(''); svgParts.push(''); // 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(''); }); // 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(''); // Escape label for SVG var safeLabel = String(label).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); if (safeLabel.length > 15) safeLabel = safeLabel.substring(0, 15) + '…'; svgParts.push('' + safeLabel + ''); }); svgParts.push(''); 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;