/** * 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 'Not available'; } 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} 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 `${val}`; } 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 ? `${d1}` : d1; const c2 = isDiff ? `${d2}` : d2; return ` ${this._escapeHtml(label)} ${c1} ${c2} `; } /** * 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 `
Metadata
${rows}
Model 1 Model 2
`; } /** * 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 ? 'Not present' : `${this._escapeHtml(shape1)} · ${this._escapeHtml(dtype1)}`; const cell2 = missingIn2 ? 'Not present' : `${this._escapeHtml(shape2)} · ${this._escapeHtml(dtype2)}`; const rowClass = (missingIn1 || missingIn2 || shapeDiff || dtypeDiff) ? 'table-warning' : ''; return ` ${this._escapeHtml(name)} ${cell1} ${cell2} `; }).join(''); const emptyMsg = allNames.size === 0 ? `No ${title.toLowerCase()} found` : ''; return `
${title}
${rows}${emptyMsg}
Name Model 1 Model 2
`; } // ─── 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 = `
Model 1: ${this._escapeHtml(name1)}
vs
Model 2: ${this._escapeHtml(name2)}
highlighted = values differ between models
${this._renderMetadataSection(meta1, meta2)} ${this._renderTensorSection(inputs1, inputs2, 'input')} ${this._renderTensorSection(outputs1, outputs2, 'output')}
`; this._container.innerHTML = html; // Wire up exit button const exitBtn = this._container.querySelector('#exitComparisonBtn'); if (exitBtn) { exitBtn.addEventListener('click', () => this.exit()); } } } window.ComparisonView = ComparisonView;