model-explorer / js /ui /graphVisualizer.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
// Export as global for browser usage
window.GraphVisualizer = GraphVisualizer;