/** * NodeDetailPanel - Displays detailed information for a selected graph node * Shows node name, opType, domain, attributes, and input/output tensors. * Listens for 'node:selected' events via EventBus and hides when no node is selected. * Requirements: 16.1, 16.2, 16.3, 16.4, 16.5 */ class NodeDetailPanel { /** * @param {string} containerId - ID of the container element */ constructor(containerId) { this._containerId = containerId; this._container = document.getElementById(containerId); this._currentNodeData = null; /** @type {Function|null} */ this._unsubscribeEvent = null; /** @type {Function|null} */ this._unsubscribeState = null; if (!this._container) { console.warn(`[NodeDetailPanel] Container #${containerId} not found`); } this._bindEvents(); this._renderGuidance(); } // ─── Private ────────────────────────────────────────────────────────────── /** * Subscribe to EventBus and StateManager for node selection changes. * @private */ _bindEvents() { // Listen for node:selected via EventBus if (typeof EventBus !== 'undefined' && typeof CONFIG !== 'undefined') { this._unsubscribeEvent = EventBus.on(CONFIG.EVENTS.NODE_SELECTED, (data) => { if (data && data.nodeData) { this._onNodeSelected(data.nodeData); } else if (data && data.nodeId) { // Minimal data — show what we have this._onNodeSelected({ id: data.nodeId }); } }); } // Listen for selectedNodeId becoming null (background click) if (typeof StateManager !== 'undefined') { this._unsubscribeState = StateManager.subscribe('selectedNodeId', (newId) => { if (!newId) { this._onNodeDeselected(); } }); } } /** * Handle node selection. * @param {Object} nodeData * @private */ _onNodeSelected(nodeData) { this._currentNodeData = nodeData; this._render(nodeData); } /** * Handle node deselection (background click). * @private */ _onNodeDeselected() { this._currentNodeData = null; this._renderGuidance(); } /** * Escape HTML special characters. * @param {string} str * @returns {string} * @private */ _escapeHtml(str) { const div = document.createElement('div'); div.appendChild(document.createTextNode(String(str))); return div.innerHTML; } /** * Format an attribute value for display. * @param {*} val * @returns {string} Escaped HTML string * @private */ _formatAttrValue(val) { if (val === null || val === undefined) { return 'null'; } if (Array.isArray(val)) { const preview = val.slice(0, 8).map((v) => String(v)).join(', '); const suffix = val.length > 8 ? `, … (+${val.length - 8})` : ''; return this._escapeHtml(`[${preview}${suffix}]`); } if (typeof val === 'object') { try { const json = JSON.stringify(val, null, 2); return `
${this._escapeHtml(json)}
`; } catch (_) { return this._escapeHtml(String(val)); } } return this._escapeHtml(String(val)); } /** * Build the header section (name, opType, domain). * @param {Object} nodeData * @returns {string} * @private */ _buildHeader(nodeData) { const name = nodeData.name || nodeData.label || nodeData.id || 'Unnamed'; const opType = nodeData.opType || 'N/A'; const domain = nodeData.domain || 'ai.onnx'; return `
${this._escapeHtml(name)}
${this._escapeHtml(opType)} ${this._escapeHtml(domain)}
`; } /** * Build the attributes section. * @param {Record} attributes * @returns {string} * @private */ _buildAttributes(attributes) { if (!attributes || Object.keys(attributes).length === 0) { return `
Attributes

No attributes

`; } const rows = Object.entries(attributes).map(([key, val]) => ` ${this._escapeHtml(key)} ${this._formatAttrValue(val)} `).join(''); return `
Attributes (${Object.keys(attributes).length})
${rows}
`; } /** * Build the input/output tensors section. * @param {Array} inputs * @param {Array} outputs * @returns {string} * @private */ _buildTensors(inputs, outputs) { const buildList = (items, label, icon, colorClass) => { if (!items || items.length === 0) { return `

No ${label.toLowerCase()}

`; } const listItems = items.map((name) => `
  • ${this._escapeHtml(String(name))}
  • ` ).join(''); return `
    ${label} (${items.length})
      ${listItems}
    `; }; return `
    ${buildList(inputs, 'Inputs', 'fa-arrow-right', 'text-primary')} ${buildList(outputs, 'Outputs', 'fa-arrow-left', 'text-success')}
    `; } /** * Render the full node detail panel. * @param {Object} nodeData * @private */ _render(nodeData) { if (!this._container) return; const nodeId = nodeData.id || ''; const html = `
    ${this._buildHeader(nodeData)}

    ${this._buildAttributes(nodeData.attributes)}
    ${this._buildTensors(nodeData.inputs, nodeData.outputs)}
    `; this._container.innerHTML = html; // Bind Trace Path button var traceBtn = this._container.querySelector('.trace-path-btn'); if (traceBtn) { traceBtn.addEventListener('click', function () { var nid = traceBtn.dataset.nodeId; if (nid && window.EventBus) { window.EventBus.emit('path:highlight-requested', { nodeId: nid }); } }); } // Bind Annotate button var annotateBtn = this._container.querySelector('.annotate-node-btn'); if (annotateBtn) { annotateBtn.addEventListener('click', function () { var nid = annotateBtn.dataset.nodeId; if (nid && window._onnxApp && window._onnxApp.getGraphAnnotation) { var ga = window._onnxApp.getGraphAnnotation(); if (ga) { ga.showAnnotationPopup(nid); } } }); } } /** * Render guidance message when no node is selected. * @private */ _renderGuidance() { if (!this._container) return; this._container.innerHTML = `

    Click a node in the graph to view its details

    `; } // ─── Public API ─────────────────────────────────────────────────────────── /** * Programmatically show details for a node. * @param {Object} nodeData */ showNode(nodeData) { if (nodeData) { this._onNodeSelected(nodeData); } } /** * Clear the panel and show guidance. */ clear() { this._currentNodeData = null; this._renderGuidance(); } /** * Get the currently displayed node data. * @returns {Object|null} */ getCurrentNode() { return this._currentNodeData; } /** * Destroy and clean up event subscriptions. */ destroy() { if (this._unsubscribeEvent) { this._unsubscribeEvent(); this._unsubscribeEvent = null; } if (this._unsubscribeState) { this._unsubscribeState(); this._unsubscribeState = null; } this._currentNodeData = null; if (this._container) { this._container.innerHTML = ''; } } } window.NodeDetailPanel = NodeDetailPanel;