/** * LiXX Cell Pack Matcher - Main Application * Uses Web Workers for non-blocking computation. */ // ============================================================================= // Application State // ============================================================================= const AppState = { cells: [], cellIdCounter: 0, worker: null, isRunning: false, results: null }; // ============================================================================= // DOM Elements // ============================================================================= const DOM = { cellsSerial: document.getElementById('cells-serial'), cellsParallel: document.getElementById('cells-parallel'), configDisplay: document.getElementById('config-display'), totalCellsNeeded: document.getElementById('total-cells-needed'), cellTbody: document.getElementById('cell-tbody'), btnAddCell: document.getElementById('btn-add-cell'), btnLoadExample: document.getElementById('btn-load-example'), btnClearAll: document.getElementById('btn-clear-all'), statCount: document.getElementById('stat-count'), statAvgCap: document.getElementById('stat-avg-cap'), statAvgIr: document.getElementById('stat-avg-ir'), weightCapacity: document.getElementById('weight-capacity'), weightIr: document.getElementById('weight-ir'), weightCapValue: document.getElementById('weight-cap-value'), weightIrValue: document.getElementById('weight-ir-value'), algorithmSelect: document.getElementById('algorithm-select'), maxIterations: document.getElementById('max-iterations'), btnStartMatching: document.getElementById('btn-start-matching'), btnStopMatching: document.getElementById('btn-stop-matching'), progressSection: document.getElementById('progress-section'), progressFill: document.getElementById('progress-fill'), progressIteration: document.getElementById('progress-iteration'), progressCombinations: document.getElementById('progress-combinations'), progressScore: document.getElementById('progress-score'), progressTime: document.getElementById('progress-time'), progressSpeed: document.getElementById('progress-speed'), progressEta: document.getElementById('progress-eta'), livePreview: document.getElementById('live-preview'), livePreviewContent: document.getElementById('live-preview-content'), resultsSection: document.getElementById('results-section'), resultScore: document.getElementById('result-score'), resultCapVariance: document.getElementById('result-cap-variance'), resultIrVariance: document.getElementById('result-ir-variance'), resultPackCapacity: document.getElementById('result-pack-capacity'), packGrid: document.getElementById('pack-grid'), resultsTbody: document.getElementById('results-tbody'), excludedCellsSection: document.getElementById('excluded-cells-section'), excludedCellsList: document.getElementById('excluded-cells-list'), btnExportJson: document.getElementById('btn-export-json'), btnExportCsv: document.getElementById('btn-export-csv'), btnCopyResults: document.getElementById('btn-copy-results'), shortcutsDialog: document.getElementById('shortcuts-dialog'), btnCloseShortcuts: document.getElementById('btn-close-shortcuts') }; // ============================================================================= // Utility Functions // ============================================================================= function formatDuration(ms) { if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; if (ms < 3600000) { const mins = Math.floor(ms / 60000); const secs = Math.round((ms % 60000) / 1000); return `${mins}m ${secs}s`; } const hours = Math.floor(ms / 3600000); const mins = Math.round((ms % 3600000) / 60000); return `${hours}h ${mins}m`; } function formatNumber(num) { if (num === Infinity) return '∞'; if (num >= 1e9) return `${(num / 1e9).toFixed(1)}B`; if (num >= 1e6) return `${(num / 1e6).toFixed(1)}M`; if (num >= 1e3) return `${(num / 1e3).toFixed(1)}K`; return num.toLocaleString(); } // ============================================================================= // Configuration Management // ============================================================================= function updateConfigDisplay() { const serial = parseInt(DOM.cellsSerial.value) || 1; const parallel = parseInt(DOM.cellsParallel.value) || 1; const total = serial * parallel; DOM.configDisplay.textContent = `${serial}S${parallel}P`; DOM.totalCellsNeeded.textContent = total; updateMatchingButtonState(); } // ============================================================================= // Cell Management // ============================================================================= function addCell(cellData = null) { const id = AppState.cellIdCounter++; const label = cellData?.label || `C${String(AppState.cells.length + 1).padStart(2, '0')}`; const capacity = cellData?.capacity || ''; const ir = cellData?.ir || ''; const cell = { id, label, capacity: capacity || null, ir: ir || null }; AppState.cells.push(cell); const row = document.createElement('tr'); row.dataset.cellId = id; row.innerHTML = ` ${AppState.cells.length} `; DOM.cellTbody.appendChild(row); row.querySelectorAll('input').forEach(input => { input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value)); input.addEventListener('input', () => updateCellData(id, input.dataset.field, input.value)); }); row.querySelector('.btn-remove').addEventListener('click', () => removeCell(id)); updateCellStats(); updateMatchingButtonState(); if (!cellData) { row.querySelector('input[data-field="capacity"]').focus(); } } function updateCellData(id, field, value) { const cell = AppState.cells.find(c => c.id === id); if (!cell) return; if (field === 'label') cell.label = value || `C${id}`; else if (field === 'capacity') cell.capacity = value ? parseFloat(value) : null; else if (field === 'ir') cell.ir = value ? parseFloat(value) : null; updateCellStats(); updateMatchingButtonState(); } function removeCell(id) { const index = AppState.cells.findIndex(c => c.id === id); if (index === -1) return; AppState.cells.splice(index, 1); const row = DOM.cellTbody.querySelector(`tr[data-cell-id="${id}"]`); if (row) row.remove(); DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => { row.querySelector('td:first-child').textContent = idx + 1; }); updateCellStats(); updateMatchingButtonState(); } function clearAllCells() { if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return; AppState.cells = []; AppState.cellIdCounter = 0; DOM.cellTbody.innerHTML = ''; updateCellStats(); updateMatchingButtonState(); } function updateCellStats() { const count = AppState.cells.length; DOM.statCount.textContent = count; if (count === 0) { DOM.statAvgCap.textContent = '-'; DOM.statAvgIr.textContent = '-'; return; } const capacities = AppState.cells.filter(c => c.capacity).map(c => c.capacity); const irs = AppState.cells.filter(c => c.ir).map(c => c.ir); DOM.statAvgCap.textContent = capacities.length > 0 ? `${Math.round(capacities.reduce((a, b) => a + b, 0) / capacities.length)} mAh` : '-'; DOM.statAvgIr.textContent = irs.length > 0 ? `${(irs.reduce((a, b) => a + b, 0) / irs.length).toFixed(1)} mΩ` : '-'; } function loadExampleData() { if (AppState.cells.length > 0 && !confirm('Replace current cells with example data?')) return; AppState.cells = []; AppState.cellIdCounter = 0; DOM.cellTbody.innerHTML = ''; const exampleCells = [ { label: 'B01', capacity: 3330, ir: 42 }, { label: 'B02', capacity: 3360, ir: 38 }, { label: 'B03', capacity: 3230, ir: 45 }, { label: 'B04', capacity: 3390, ir: 41 }, { label: 'B05', capacity: 3280, ir: 44 }, { label: 'B06', capacity: 3350, ir: 39 }, { label: 'B07', capacity: 3350, ir: 40 }, { label: 'B08', capacity: 3490, ir: 36 }, { label: 'B09', capacity: 3280, ir: 43 }, { label: 'B10', capacity: 3420, ir: 37 }, { label: 'B11', capacity: 3350, ir: 41 }, { label: 'B12', capacity: 3420, ir: 38 }, { label: 'B13', capacity: 3150, ir: 52 }, { label: 'B14', capacity: 3380, ir: 40 } ]; exampleCells.forEach(cell => addCell(cell)); } // ============================================================================= // Weight Sliders // ============================================================================= function updateWeights(source) { const capWeight = parseInt(DOM.weightCapacity.value); if (source === 'capacity') DOM.weightIr.value = 100 - capWeight; else DOM.weightCapacity.value = 100 - parseInt(DOM.weightIr.value); DOM.weightCapValue.textContent = `${DOM.weightCapacity.value}%`; DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`; } // ============================================================================= // Matching Control (Web Worker) // ============================================================================= function updateMatchingButtonState() { const serial = parseInt(DOM.cellsSerial.value) || 1; const parallel = parseInt(DOM.cellsParallel.value) || 1; const needed = serial * parallel; const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0); const canStart = validCells.length >= needed && !AppState.isRunning; DOM.btnStartMatching.disabled = !canStart; DOM.btnStartMatching.title = validCells.length < needed ? `Need at least ${needed} cells with capacity data` : ''; } function startMatching() { if (AppState.isRunning) return; const serial = parseInt(DOM.cellsSerial.value) || 1; const parallel = parseInt(DOM.cellsParallel.value) || 1; const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0); if (validCells.length < serial * parallel) { alert(`Need at least ${serial * parallel} cells with capacity data.`); return; } AppState.isRunning = true; DOM.progressSection.hidden = false; DOM.resultsSection.hidden = true; DOM.btnStartMatching.disabled = true; DOM.progressFill.style.width = '0%'; DOM.progressIteration.textContent = '0'; DOM.progressCombinations.textContent = '-'; DOM.progressScore.textContent = '-'; DOM.progressTime.textContent = '0s'; DOM.progressSpeed.textContent = '-'; DOM.progressEta.textContent = '-'; DOM.livePreview.hidden = true; const algorithmType = DOM.algorithmSelect.value; const maxIterations = parseInt(DOM.maxIterations.value) || 5000; const capacityWeight = parseInt(DOM.weightCapacity.value) / 100; const irWeight = parseInt(DOM.weightIr.value) / 100; AppState.worker = new Worker('js/matching-worker.js'); AppState.worker.onmessage = function (e) { const { type, data } = e.data; if (type === 'progress') updateProgress(data); else if (type === 'complete') handleComplete(data); else if (type === 'error') handleError(data); }; AppState.worker.onerror = function (error) { console.error('Worker error:', error); handleError(error.message); }; AppState.worker.postMessage({ type: 'start', data: { cells: validCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })), serial, parallel, algorithm: algorithmType, options: { maxIterations, capacityWeight, irWeight } } }); } function stopMatching() { if (AppState.worker) AppState.worker.postMessage({ type: 'stop' }); } function updateProgress(progress) { const percent = (progress.iteration / progress.maxIterations) * 100; DOM.progressFill.style.width = `${percent}%`; DOM.progressIteration.textContent = formatNumber(progress.iteration); DOM.progressCombinations.textContent = progress.totalCombinations ? `${formatNumber(progress.evaluatedCombinations)} / ${formatNumber(progress.totalCombinations)}` : formatNumber(progress.evaluatedCombinations || progress.iteration); DOM.progressScore.textContent = progress.bestScore.toFixed(4); DOM.progressTime.textContent = formatDuration(progress.elapsedTime); if (progress.iterationsPerSecond > 0) { DOM.progressSpeed.textContent = `${formatNumber(Math.round(progress.iterationsPerSecond))}/s`; } if (progress.eta > 0 && progress.eta < Infinity) { DOM.progressEta.textContent = formatDuration(progress.eta); } else if (progress.iteration >= progress.maxIterations * 0.9) { DOM.progressEta.textContent = 'Almost done...'; } if (progress.currentBest && progress.currentBest.config) { renderLivePreview(progress.currentBest.config); } } function renderLivePreview(config) { DOM.livePreview.hidden = false; DOM.livePreviewContent.innerHTML = config.map((group, idx) => { const cells = group.map(c => c.label).join('+'); const totalCap = group.reduce((sum, c) => sum + c.capacity, 0); return `
S${idx + 1}:${cells}${totalCap}
`; }).join(''); } function handleComplete(results) { AppState.isRunning = false; AppState.results = results; if (AppState.worker) { AppState.worker.terminate(); AppState.worker = null; } DOM.progressSection.hidden = true; DOM.btnStartMatching.disabled = false; updateMatchingButtonState(); displayResults(results); } function handleError(errorMessage) { AppState.isRunning = false; if (AppState.worker) { AppState.worker.terminate(); AppState.worker = null; } DOM.progressSection.hidden = true; DOM.btnStartMatching.disabled = false; updateMatchingButtonState(); alert('An error occurred during matching: ' + errorMessage); } // ============================================================================= // Results Display // ============================================================================= function displayResults(results) { DOM.resultsSection.hidden = false; DOM.resultScore.textContent = results.score.toFixed(3); DOM.resultCapVariance.textContent = `${results.capacityCV.toFixed(2)}%`; DOM.resultIrVariance.textContent = results.irCV ? `${results.irCV.toFixed(2)}%` : 'N/A'; const packCapacity = Math.min(...results.groupCapacities); DOM.resultPackCapacity.textContent = `${packCapacity} mAh`; renderPackVisualization(results); renderResultsTable(results); if (results.excludedCells.length > 0) { DOM.excludedCellsSection.hidden = false; DOM.excludedCellsList.textContent = results.excludedCells.map(c => c.label).join(', '); } else { DOM.excludedCellsSection.hidden = true; } DOM.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function renderPackVisualization(results) { const config = results.configuration; const allCapacities = config.flat().map(c => c.capacity); const minCap = Math.min(...allCapacities); const maxCap = Math.max(...allCapacities); const range = maxCap - minCap || 1; DOM.packGrid.innerHTML = ''; config.forEach((group, groupIdx) => { const row = document.createElement('div'); row.className = 'pack-row'; const label = document.createElement('span'); label.className = 'pack-row-label'; label.textContent = `S${groupIdx + 1}`; row.appendChild(label); const cellsContainer = document.createElement('div'); cellsContainer.className = 'pack-cells'; group.forEach(cell => { const cellEl = document.createElement('div'); cellEl.className = 'pack-cell'; const normalized = (cell.capacity - minCap) / range; const hue = normalized * 120; cellEl.style.backgroundColor = `hsl(${hue}, 70%, 45%)`; cellEl.innerHTML = `${cell.label}${cell.capacity} mAh${cell.ir ? `${cell.ir} mΩ` : ''}`; cellsContainer.appendChild(cellEl); }); row.appendChild(cellsContainer); DOM.packGrid.appendChild(row); }); } function renderResultsTable(results) { const config = results.configuration; const avgCapacity = results.groupCapacities.reduce((a, b) => a + b, 0) / results.groupCapacities.length; DOM.resultsTbody.innerHTML = ''; config.forEach((group, idx) => { const groupCapacity = group.reduce((sum, c) => sum + c.capacity, 0); const deviation = ((groupCapacity - avgCapacity) / avgCapacity * 100); const irsWithValues = group.filter(c => c.ir); const avgIr = irsWithValues.length > 0 ? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length : null; let deviationClass = 'deviation-good'; if (Math.abs(deviation) > 2) deviationClass = 'deviation-warning'; if (Math.abs(deviation) > 5) deviationClass = 'deviation-bad'; const row = document.createElement('tr'); row.innerHTML = `S${idx + 1}${group.map(c => c.label).join(' + ')}${groupCapacity} mAh${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'}${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}%`; DOM.resultsTbody.appendChild(row); }); } // ============================================================================= // Export Functions // ============================================================================= function exportJson() { if (!AppState.results) return; const data = { configuration: `${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`, timestamp: new Date().toISOString(), score: AppState.results.score, capacityCV: AppState.results.capacityCV, irCV: AppState.results.irCV, groups: AppState.results.configuration.map((group, idx) => ({ group: `S${idx + 1}`, cells: group.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })), totalCapacity: group.reduce((sum, c) => sum + c.capacity, 0) })), excludedCells: AppState.results.excludedCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })) }; downloadFile(JSON.stringify(data, null, 2), 'cell-matching-results.json', 'application/json'); } function exportCsv() { if (!AppState.results) return; const lines = ['Group,Cell Label,Capacity (mAh),IR (mΩ),Group Total']; AppState.results.configuration.forEach((group, idx) => { const groupTotal = group.reduce((sum, c) => sum + c.capacity, 0); group.forEach((cell, cellIdx) => { lines.push(`S${idx + 1},${cell.label},${cell.capacity},${cell.ir || ''},${cellIdx === 0 ? groupTotal : ''}`); }); }); if (AppState.results.excludedCells.length > 0) { lines.push('', 'Excluded Cells'); AppState.results.excludedCells.forEach(cell => lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`)); } downloadFile(lines.join('\n'), 'cell-matching-results.csv', 'text/csv'); } async function copyResults() { if (!AppState.results) return; const config = AppState.results.configuration; const lines = [`Cell Matching Results - ${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`, `Score: ${AppState.results.score.toFixed(3)}`, `Capacity CV: ${AppState.results.capacityCV.toFixed(2)}%`, '', 'Pack Configuration:']; config.forEach((group, idx) => { const cells = group.map(c => `${c.label} (${c.capacity}mAh)`).join(' + '); const total = group.reduce((sum, c) => sum + c.capacity, 0); lines.push(` S${idx + 1}: ${cells} = ${total}mAh`); }); if (AppState.results.excludedCells.length > 0) { lines.push('', `Excluded: ${AppState.results.excludedCells.map(c => c.label).join(', ')}`); } try { await navigator.clipboard.writeText(lines.join('\n')); DOM.btnCopyResults.textContent = 'Copied!'; setTimeout(() => { DOM.btnCopyResults.textContent = 'Copy to Clipboard'; }, 2000); } catch (err) { console.error('Failed to copy:', err); } } function downloadFile(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ============================================================================= // Keyboard Navigation & Event Listeners // ============================================================================= function setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { if (e.altKey && e.key === 'a') { e.preventDefault(); addCell(); } if (e.altKey && e.key === 's') { e.preventDefault(); if (!DOM.btnStartMatching.disabled) startMatching(); } if (e.altKey && e.key === 'e') { e.preventDefault(); loadExampleData(); } if (e.key === 'Escape') { if (AppState.isRunning) stopMatching(); if (DOM.shortcutsDialog.open) DOM.shortcutsDialog.close(); } if (e.key === '?' && !e.target.matches('input, textarea')) { e.preventDefault(); DOM.shortcutsDialog.showModal(); } }); } function initEventListeners() { DOM.cellsSerial.addEventListener('input', updateConfigDisplay); DOM.cellsParallel.addEventListener('input', updateConfigDisplay); DOM.btnAddCell.addEventListener('click', () => addCell()); DOM.btnLoadExample.addEventListener('click', loadExampleData); DOM.btnClearAll.addEventListener('click', clearAllCells); DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity')); DOM.weightIr.addEventListener('input', () => updateWeights('ir')); DOM.btnStartMatching.addEventListener('click', startMatching); DOM.btnStopMatching.addEventListener('click', stopMatching); DOM.btnExportJson.addEventListener('click', exportJson); DOM.btnExportCsv.addEventListener('click', exportCsv); DOM.btnCopyResults.addEventListener('click', copyResults); DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close()); } // ============================================================================= // Initialization // ============================================================================= function init() { initEventListeners(); setupKeyboardShortcuts(); updateConfigDisplay(); updateWeights('capacity'); updateMatchingButtonState(); for (let i = 0; i < 3; i++) addCell(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();