Spaces:
Running
Running
| /** | |
| * 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 '<span class="text-muted fst-italic">null</span>'; | |
| } | |
| 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 `<pre class="mb-0 small">${this._escapeHtml(json)}</pre>`; | |
| } 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 ` | |
| <div class="node-detail-header mb-3"> | |
| <h6 class="mb-1 text-truncate" title="${this._escapeHtml(name)}"> | |
| <i class="fas fa-circle-nodes me-1 text-primary"></i>${this._escapeHtml(name)} | |
| </h6> | |
| <div class="small"> | |
| <span class="badge bg-info me-1">${this._escapeHtml(opType)}</span> | |
| <span class="badge bg-secondary">${this._escapeHtml(domain)}</span> | |
| </div> | |
| </div>`; | |
| } | |
| /** | |
| * Build the attributes section. | |
| * @param {Record<string, any>} attributes | |
| * @returns {string} | |
| * @private | |
| */ | |
| _buildAttributes(attributes) { | |
| if (!attributes || Object.keys(attributes).length === 0) { | |
| return ` | |
| <div class="node-detail-section mb-3"> | |
| <h6 class="small text-uppercase text-secondary mb-2"> | |
| <i class="fas fa-sliders me-1"></i>Attributes | |
| </h6> | |
| <p class="text-muted small mb-0">No attributes</p> | |
| </div>`; | |
| } | |
| const rows = Object.entries(attributes).map(([key, val]) => ` | |
| <tr> | |
| <td class="text-nowrap pe-2 small fw-medium">${this._escapeHtml(key)}</td> | |
| <td class="small">${this._formatAttrValue(val)}</td> | |
| </tr>`).join(''); | |
| return ` | |
| <div class="node-detail-section mb-3"> | |
| <h6 class="small text-uppercase text-secondary mb-2"> | |
| <i class="fas fa-sliders me-1"></i>Attributes (${Object.keys(attributes).length}) | |
| </h6> | |
| <table class="table table-sm table-borderless mb-0"> | |
| <tbody>${rows}</tbody> | |
| </table> | |
| </div>`; | |
| } | |
| /** | |
| * Build the input/output tensors section. | |
| * @param {Array<string>} inputs | |
| * @param {Array<string>} outputs | |
| * @returns {string} | |
| * @private | |
| */ | |
| _buildTensors(inputs, outputs) { | |
| const buildList = (items, label, icon, colorClass) => { | |
| if (!items || items.length === 0) { | |
| return `<p class="text-muted small mb-0">No ${label.toLowerCase()}</p>`; | |
| } | |
| const listItems = items.map((name) => | |
| `<li class="list-group-item py-1 px-2 small">${this._escapeHtml(String(name))}</li>` | |
| ).join(''); | |
| return ` | |
| <h6 class="small text-uppercase ${colorClass} mb-1"> | |
| <i class="fas ${icon} me-1"></i>${label} (${items.length}) | |
| </h6> | |
| <ul class="list-group list-group-flush mb-2">${listItems}</ul>`; | |
| }; | |
| return ` | |
| <div class="node-detail-section mb-3"> | |
| ${buildList(inputs, 'Inputs', 'fa-arrow-right', 'text-primary')} | |
| ${buildList(outputs, 'Outputs', 'fa-arrow-left', 'text-success')} | |
| </div>`; | |
| } | |
| /** | |
| * Render the full node detail panel. | |
| * @param {Object} nodeData | |
| * @private | |
| */ | |
| _render(nodeData) { | |
| if (!this._container) return; | |
| const nodeId = nodeData.id || ''; | |
| const html = ` | |
| <div class="node-detail-panel-content"> | |
| ${this._buildHeader(nodeData)} | |
| <div class="d-flex gap-2 mb-2"> | |
| <button class="btn btn-sm btn-outline-primary flex-fill trace-path-btn" data-node-id="${this._escapeHtml(nodeId)}" title="Highlight data flow path through this node"> | |
| <i class="fas fa-route me-1"></i>Trace Path | |
| </button> | |
| <button class="btn btn-sm btn-outline-warning flex-fill annotate-node-btn" data-node-id="${this._escapeHtml(nodeId)}" title="Add or edit annotation for this node"> | |
| <i class="fas fa-sticky-note me-1"></i>Annotate | |
| </button> | |
| </div> | |
| <hr class="my-2"> | |
| ${this._buildAttributes(nodeData.attributes)} | |
| <hr class="my-2"> | |
| ${this._buildTensors(nodeData.inputs, nodeData.outputs)} | |
| </div>`; | |
| 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 = ` | |
| <div class="text-center text-muted py-4"> | |
| <i class="fas fa-hand-pointer fa-2x mb-2 d-block"></i> | |
| <p class="mb-0 small">Click a node in the graph to view its details</p> | |
| </div>`; | |
| } | |
| // βββ 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; | |