model-explorer / js /ui /comparisonView.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* ComparisonView - Side-by-side model comparison
* Requirements: 14.1, 14.2, 14.3, 14.4, 14.5
*/
class ComparisonView {
/**
* @param {string} containerId - ID of the container element (#modelDetailsContainer)
*/
constructor(containerId) {
this._containerId = containerId;
this._container = document.getElementById(containerId);
this._originalContent = null;
if (!this._container) {
console.warn(`[ComparisonView] Container #${containerId} not found`);
}
}
// ─── Private Helpers ──────────────────────────────────────────────────────
/**
* Escape HTML special characters.
* @param {string} str
* @returns {string}
*/
_escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(String(str == null ? '' : str)));
return div.innerHTML;
}
/**
* Return display value or a "Not available" placeholder.
* @param {*} value
* @returns {string}
*/
_displayValue(value) {
if (value === null || value === undefined || value === '') {
return '<span class="text-muted fst-italic">Not available</span>';
}
return this._escapeHtml(String(value));
}
/**
* Format bytes to human-readable string.
* @param {number} bytes
* @returns {string}
*/
_formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/**
* Format a tensor shape array.
* @param {Array<number|string>} shape
* @returns {string}
*/
_formatShape(shape) {
if (!shape || shape.length === 0) return '[]';
const dims = shape.map((d) => {
if (typeof d === 'string') return d;
if (d < 0) return 'dynamic';
return String(d);
});
return `[${dims.join(', ')}]`;
}
/**
* Resolve numeric data type to string.
* @param {number|string} dataType
* @returns {string}
*/
_resolveDataType(dataType) {
if (typeof dataType === 'string') return dataType;
if (window.CONFIG && CONFIG.DATA_TYPES && CONFIG.DATA_TYPES[dataType]) {
return CONFIG.DATA_TYPES[dataType];
}
return String(dataType == null ? '' : dataType);
}
/**
* Wrap a value in a highlight span if it differs from the other value.
* @param {string} val - escaped/display value for this model
* @param {string} otherVal - escaped/display value for the other model
* @returns {string}
*/
_maybeHighlight(val, otherVal) {
if (val !== otherVal) {
return `<span class="comparison-diff">${val}</span>`;
}
return val;
}
/**
* Build a metadata comparison table row.
* @param {string} label
* @param {*} val1
* @param {*} val2
* @returns {string}
*/
_metaRow(label, val1, val2) {
const d1 = this._displayValue(val1);
const d2 = this._displayValue(val2);
const isDiff = String(val1 ?? '') !== String(val2 ?? '');
const c1 = isDiff ? `<span class="comparison-diff">${d1}</span>` : d1;
const c2 = isDiff ? `<span class="comparison-diff">${d2}</span>` : d2;
return `
<tr>
<th scope="row" class="text-nowrap pe-2 text-secondary small" style="width:30%">${this._escapeHtml(label)}</th>
<td class="pe-2">${c1}</td>
<td>${c2}</td>
</tr>`;
}
/**
* Build metadata section HTML.
* @param {object} meta1
* @param {object} meta2
* @returns {string}
*/
_renderMetadataSection(meta1, meta2) {
meta1 = meta1 || {};
meta2 = meta2 || {};
const fields = [
['Producer Name', meta1.producerName, meta2.producerName],
['Producer Version', meta1.producerVersion, meta2.producerVersion],
['Opset Version', meta1.opsetVersion, meta2.opsetVersion],
['IR Version', meta1.irVersion, meta2.irVersion],
['File Size',
meta1.fileSize ? this._formatBytes(meta1.fileSize) : null,
meta2.fileSize ? this._formatBytes(meta2.fileSize) : null],
];
const rows = fields.map(([label, v1, v2]) => this._metaRow(label, v1, v2)).join('');
return `
<div class="card mb-3">
<div class="card-header py-2">
<h6 class="mb-0"><i class="fas fa-info-circle me-1"></i>Metadata</h6>
</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<thead class="table-light">
<tr>
<th style="width:30%"></th>
<th class="text-primary">Model 1</th>
<th class="text-success">Model 2</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}
/**
* Serialize a tensor to a comparable string key.
* @param {object} tensor
* @returns {string}
*/
_tensorKey(tensor) {
return `${tensor.name}|${this._formatShape(tensor.shape)}|${this._resolveDataType(tensor.dataType)}`;
}
/**
* Build tensor list rows for comparison.
* @param {Array} tensors1
* @param {Array} tensors2
* @param {'input'|'output'} kind
* @returns {string}
*/
_renderTensorSection(tensors1, tensors2, kind) {
tensors1 = tensors1 || [];
tensors2 = tensors2 || [];
const keys1 = new Set(tensors1.map((t) => this._tensorKey(t)));
const keys2 = new Set(tensors2.map((t) => this._tensorKey(t)));
const allNames = new Set([
...tensors1.map((t) => t.name),
...tensors2.map((t) => t.name),
]);
const map1 = Object.fromEntries(tensors1.map((t) => [t.name, t]));
const map2 = Object.fromEntries(tensors2.map((t) => [t.name, t]));
const title = kind === 'input' ? 'Inputs' : 'Outputs';
const icon = kind === 'input' ? 'fa-arrow-right' : 'fa-arrow-left';
const headerClass = kind === 'input' ? 'text-primary' : 'text-success';
const rows = [...allNames].map((name) => {
const t1 = map1[name];
const t2 = map2[name];
const shape1 = t1 ? this._formatShape(t1.shape) : 'β€”';
const shape2 = t2 ? this._formatShape(t2.shape) : 'β€”';
const dtype1 = t1 ? this._resolveDataType(t1.dataType) : 'β€”';
const dtype2 = t2 ? this._resolveDataType(t2.dataType) : 'β€”';
const shapeDiff = shape1 !== shape2;
const dtypeDiff = dtype1 !== dtype2;
const missingIn1 = !t1;
const missingIn2 = !t2;
const cell1 = missingIn1
? '<span class="text-muted fst-italic">Not present</span>'
: `<span class="${shapeDiff ? 'comparison-diff' : ''}">${this._escapeHtml(shape1)}</span>
<span class="text-muted mx-1">Β·</span>
<span class="${dtypeDiff ? 'comparison-diff' : ''}">${this._escapeHtml(dtype1)}</span>`;
const cell2 = missingIn2
? '<span class="text-muted fst-italic">Not present</span>'
: `<span class="${shapeDiff ? 'comparison-diff' : ''}">${this._escapeHtml(shape2)}</span>
<span class="text-muted mx-1">Β·</span>
<span class="${dtypeDiff ? 'comparison-diff' : ''}">${this._escapeHtml(dtype2)}</span>`;
const rowClass = (missingIn1 || missingIn2 || shapeDiff || dtypeDiff) ? 'table-warning' : '';
return `
<tr class="${rowClass}">
<td class="text-truncate small fw-medium" style="max-width:160px" title="${this._escapeHtml(name)}">${this._escapeHtml(name)}</td>
<td class="small">${cell1}</td>
<td class="small">${cell2}</td>
</tr>`;
}).join('');
const emptyMsg = allNames.size === 0
? `<tr><td colspan="3" class="text-muted fst-italic text-center">No ${title.toLowerCase()} found</td></tr>`
: '';
return `
<div class="card mb-3">
<div class="card-header py-2">
<h6 class="mb-0 ${headerClass}"><i class="fas ${icon} me-1"></i>${title}</h6>
</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<thead class="table-light">
<tr>
<th style="width:35%">Name</th>
<th class="text-primary">Model 1</th>
<th class="text-success">Model 2</th>
</tr>
</thead>
<tbody>${rows}${emptyMsg}</tbody>
</table>
</div>
</div>`;
}
// ─── Public API ───────────────────────────────────────────────────────────
/**
* Enter comparison mode: save current DOM, render diff view.
* @param {ParsedModel} model1
* @param {ParsedModel} model2
*/
enter(model1, model2) {
if (!this._container) return;
// Save original content so we can restore it on exit
this._originalContent = this._container.innerHTML;
// Update state
if (window.StateManager) {
StateManager.setViewMode('comparison');
StateManager.setComparisonModel(model2);
}
this.renderDiff(model1, model2);
// Emit event
if (window.EventBus) {
EventBus.emit(CONFIG.EVENTS.COMPARISON_STARTED, { model1, model2 });
}
}
/**
* Exit comparison mode: restore original single-model view.
*/
exit() {
if (!this._container) return;
if (this._originalContent !== null) {
this._container.innerHTML = this._originalContent;
this._originalContent = null;
}
// Update state
if (window.StateManager) {
StateManager.setViewMode('single');
StateManager.setComparisonModel(null);
}
// Emit event
if (window.EventBus) {
EventBus.emit(CONFIG.EVENTS.COMPARISON_ENDED, {});
}
}
/**
* Render the diff view for two models.
* @param {ParsedModel} model1
* @param {ParsedModel} model2
*/
renderDiff(model1, model2) {
if (!this._container) return;
const meta1 = (model1 && model1.metadata) ? model1.metadata : {};
const meta2 = (model2 && model2.metadata) ? model2.metadata : {};
const name1 = meta1.fileName || 'Model 1';
const name2 = meta2.fileName || 'Model 2';
const inputs1 = (model1 && model1.inputs) ? model1.inputs : [];
const inputs2 = (model2 && model2.inputs) ? model2.inputs : [];
const outputs1 = (model1 && model1.outputs) ? model1.outputs : [];
const outputs2 = (model2 && model2.outputs) ? model2.outputs : [];
const html = `
<div id="comparisonViewRoot" class="comparison-view">
<!-- Header bar -->
<div class="d-flex align-items-center justify-content-between mb-3 p-3 bg-light border rounded">
<div class="d-flex align-items-center gap-3">
<i class="fas fa-columns text-secondary fs-5"></i>
<div>
<span class="fw-semibold text-primary me-2">
<i class="fas fa-circle me-1" style="font-size:0.6rem"></i>Model 1:
</span>
<span class="text-dark">${this._escapeHtml(name1)}</span>
</div>
<span class="text-muted">vs</span>
<div>
<span class="fw-semibold text-success me-2">
<i class="fas fa-circle me-1" style="font-size:0.6rem"></i>Model 2:
</span>
<span class="text-dark">${this._escapeHtml(name2)}</span>
</div>
</div>
<button id="exitComparisonBtn" class="btn btn-outline-secondary btn-sm" aria-label="Exit comparison mode">
<i class="fas fa-times me-1"></i>Exit Comparison
</button>
</div>
<!-- Legend -->
<div class="mb-3 small text-muted">
<span class="comparison-diff px-1 me-1">highlighted</span> = values differ between models
</div>
<!-- Metadata comparison -->
${this._renderMetadataSection(meta1, meta2)}
<!-- Inputs comparison -->
${this._renderTensorSection(inputs1, inputs2, 'input')}
<!-- Outputs comparison -->
${this._renderTensorSection(outputs1, outputs2, 'output')}
</div>`;
this._container.innerHTML = html;
// Wire up exit button
const exitBtn = this._container.querySelector('#exitComparisonBtn');
if (exitBtn) {
exitBtn.addEventListener('click', () => this.exit());
}
}
}
window.ComparisonView = ComparisonView;