model-explorer / js /ui /nodeDetailPanel.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* 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;