model-explorer / js /ui /graphExport.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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;