/**
* 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();
}
// =============================================================================
// Combination Calculator
// =============================================================================
function binomial(n, k) {
if (k > n) return 0;
if (k === 0 || k === n) return 1;
let result = 1;
for (let i = 0; i < k; i++) {
result = result * (n - i) / (i + 1);
}
return Math.round(result);
}
function calculateTotalCombinations(numCells, serial, parallel) {
const totalNeeded = serial * parallel;
if (numCells < totalNeeded) return 0;
let total = 1;
let remaining = numCells;
for (let i = 0; i < serial; i++) {
total *= binomial(remaining, parallel);
remaining -= parallel;
}
return total;
}
function updateMaxIterations() {
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);
const totalCombinations = calculateTotalCombinations(validCells.length, serial, parallel);
// Set max iterations: use total combinations if reasonable, cap at 1,000,000
const suggested = Math.min(Math.max(totalCombinations, 1000), 1000000);
DOM.maxIterations.value = suggested;
}
// =============================================================================
// 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();
updateMaxIterations();
}
// =============================================================================
// 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Ω` : '-';
updateMaxIterations();
}
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() {
console.log("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`;
}
DOM.progressEta.textContent = formatDuration(progress.eta);
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();