Rewrite as static webapp (#1)
Reviewed-on: #1 Co-authored-by: localhorst <localhorst@mosad.xyz> Co-committed-by: localhorst <localhorst@mosad.xyz>
This commit is contained in:
602
js/app.js
Normal file
602
js/app.js
Normal file
@ -0,0 +1,602 @@
|
||||
/**
|
||||
* 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 = `
|
||||
<td>${AppState.cells.length}</td>
|
||||
<td><input type="text" class="cell-label-input" value="${label}" aria-label="Cell label" data-field="label"></td>
|
||||
<td><input type="number" min="0" max="99999" value="${capacity}" aria-label="Capacity in mAh" data-field="capacity" placeholder="mAh"></td>
|
||||
<td><input type="number" min="0" max="9999" step="0.1" value="${ir}" aria-label="Internal resistance in milliohms" data-field="ir" placeholder="optional"></td>
|
||||
<td><button type="button" class="btn-remove" aria-label="Remove cell" data-remove="${id}">✕</button></td>
|
||||
`;
|
||||
|
||||
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 `<div class="live-preview-group"><span class="group-label">S${idx + 1}:</span><span class="group-cells">${cells}</span><span class="group-cap">${totalCap}</span></div>`;
|
||||
}).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 = `<span class="cell-label">${cell.label}</span><span class="cell-capacity">${cell.capacity} mAh</span>${cell.ir ? `<span class="cell-ir">${cell.ir} mΩ</span>` : ''}`;
|
||||
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 = `<td>S${idx + 1}</td><td>${group.map(c => c.label).join(' + ')}</td><td>${groupCapacity} mAh</td><td>${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'}</td><td class="${deviationClass}">${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}%</td>`;
|
||||
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();
|
||||
379
js/matching-worker.js
Normal file
379
js/matching-worker.js
Normal file
@ -0,0 +1,379 @@
|
||||
/**
|
||||
* LiXX Cell Pack Matcher - Web Worker
|
||||
*
|
||||
* Runs matching algorithms in a background thread to keep the UI responsive.
|
||||
* Communicates with the main thread via postMessage.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate the coefficient of variation (CV) as a percentage.
|
||||
*/
|
||||
function coefficientOfVariation(values) {
|
||||
if (!values || values.length === 0) return 0;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
if (mean === 0) return 0;
|
||||
const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length;
|
||||
return (Math.sqrt(variance) / mean) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array in place using Fisher-Yates algorithm.
|
||||
*/
|
||||
function shuffleArray(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone an array of arrays.
|
||||
*/
|
||||
function cloneConfiguration(arr) {
|
||||
return arr.map(group => [...group]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle cells based on random
|
||||
*/
|
||||
function shuffleCells(cells) {
|
||||
const shuffled = [...cells];
|
||||
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Scoring Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate the match score for a pack configuration.
|
||||
* Lower score = better match.
|
||||
*/
|
||||
function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) {
|
||||
const groupCapacities = configuration.map(group =>
|
||||
group.reduce((sum, cell) => sum + cell.capacity, 0)
|
||||
);
|
||||
|
||||
const groupIRs = configuration.map(group => {
|
||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||
if (irsWithValues.length === 0) return null;
|
||||
return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length;
|
||||
}).filter(ir => ir !== null);
|
||||
|
||||
const withinGroupIRVariances = configuration.map(group => {
|
||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||
if (irsWithValues.length < 2) return 0;
|
||||
const irs = irsWithValues.map(cell => cell.ir);
|
||||
return coefficientOfVariation(irs);
|
||||
});
|
||||
|
||||
const capacityCV = coefficientOfVariation(groupCapacities);
|
||||
const avgWithinGroupIRCV = withinGroupIRVariances.length > 0
|
||||
? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length
|
||||
: 0;
|
||||
|
||||
const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV);
|
||||
|
||||
return {
|
||||
score,
|
||||
capacityCV,
|
||||
irCV: avgWithinGroupIRCV,
|
||||
groupCapacities,
|
||||
groupIRs,
|
||||
withinGroupIRVariances
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Statistics Tracker
|
||||
// =============================================================================
|
||||
|
||||
class StatsTracker {
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
this.lastProgressTime = this.startTime;
|
||||
this.lastProgressIteration = 0;
|
||||
this.speedHistory = [];
|
||||
this.windowSize = 5; // Rolling window for speed averaging
|
||||
this.lastEta = null;
|
||||
this.lastEtaUpdate = 0;
|
||||
}
|
||||
|
||||
getStats(currentIteration, maxIterations) {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - this.startTime;
|
||||
|
||||
// Calculate speed based on iterations since last progress update
|
||||
const iterationsDelta = currentIteration - this.lastProgressIteration;
|
||||
const timeDelta = now - this.lastProgressTime;
|
||||
|
||||
if (timeDelta > 0 && iterationsDelta > 0) {
|
||||
const currentSpeed = (iterationsDelta / timeDelta) * 1000; // iterations per second
|
||||
this.speedHistory.push(currentSpeed);
|
||||
if (this.speedHistory.length > this.windowSize) {
|
||||
this.speedHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.lastProgressTime = now;
|
||||
this.lastProgressIteration = currentIteration;
|
||||
|
||||
// Average speed from history
|
||||
const avgSpeed = this.speedHistory.length > 0
|
||||
? this.speedHistory.reduce((a, b) => a + b, 0) / this.speedHistory.length
|
||||
: 0;
|
||||
|
||||
const remainingIterations = maxIterations - currentIteration;
|
||||
const eta = avgSpeed > 0 ? (remainingIterations / avgSpeed) * 1000 : 0;
|
||||
|
||||
// Only update ETA every 500ms to avoid flickering
|
||||
if (now - this.lastEtaUpdate > 500 || this.lastEta === null) {
|
||||
this.lastEta = eta;
|
||||
this.lastEtaUpdate = now;
|
||||
}
|
||||
|
||||
return {
|
||||
elapsedTime,
|
||||
eta: this.lastEta,
|
||||
iterationsPerSecond: avgSpeed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Exhaustive Search
|
||||
// =============================================================================
|
||||
|
||||
class ExhaustiveSearch {
|
||||
constructor(cells, serial, parallel, options = {}) {
|
||||
this.cells = shuffleCells(cells);
|
||||
this.serial = serial;
|
||||
this.parallel = parallel;
|
||||
this.totalCellsNeeded = serial * parallel;
|
||||
|
||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||
this.irWeight = options.irWeight ?? 0.3;
|
||||
this.maxIterations = options.maxIterations || 100000;
|
||||
|
||||
this.stopped = false;
|
||||
this.bestSolution = null;
|
||||
this.bestScore = Infinity;
|
||||
this.stats = new StatsTracker();
|
||||
}
|
||||
|
||||
stop() {
|
||||
console.log("ExhaustiveSearch: stop requested")
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
*combinations(array, k) {
|
||||
if (k === 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
if (array.length < k) return;
|
||||
|
||||
const [first, ...rest] = array;
|
||||
for (const combo of this.combinations(rest, k - 1)) {
|
||||
yield [first, ...combo];
|
||||
}
|
||||
yield* this.combinations(rest, k);
|
||||
}
|
||||
|
||||
*generatePartitions(cells, groupSize, numGroups) {
|
||||
if (numGroups === 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (cells.length < groupSize * numGroups) return;
|
||||
|
||||
for (const group of this.combinations(cells, groupSize)) {
|
||||
const remaining = cells.filter(c => !group.includes(c));
|
||||
for (const rest of this.generatePartitions(remaining, groupSize, numGroups - 1)) {
|
||||
yield [group, ...rest];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calculateTotalCombinations() {
|
||||
// Formula: C(n, k) * C(n-k, k) * ... / numGroups! for identical groups
|
||||
const n = this.cells.length;
|
||||
const k = this.parallel;
|
||||
const numGroups = this.serial;
|
||||
|
||||
let total = 1;
|
||||
let remaining = n;
|
||||
|
||||
for (let i = 0; i < numGroups; i++) {
|
||||
total *= this.binomial(remaining, k);
|
||||
remaining -= k;
|
||||
}
|
||||
|
||||
// Divide by numGroups! if groups are interchangeable
|
||||
// (but for battery packs, position matters, so we don't divide)
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
run() {
|
||||
let iteration = 0;
|
||||
const totalCombinations = this.calculateTotalCombinations();
|
||||
|
||||
const cellCombos = this.cells.length > this.totalCellsNeeded
|
||||
? this.combinations(this.cells, this.totalCellsNeeded)
|
||||
: [[...this.cells]];
|
||||
|
||||
for (const cellSubset of cellCombos) {
|
||||
if (this.stopped) {
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
|
||||
for (const partition of this.generatePartitions(cellSubset, this.parallel, this.serial)) {
|
||||
if (this.stopped) {
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
|
||||
const scoreResult = calculateScore(partition, this.capacityWeight, this.irWeight);
|
||||
|
||||
if (scoreResult.score < this.bestScore) {
|
||||
this.bestScore = scoreResult.score;
|
||||
this.bestSolution = { config: partition, ...scoreResult };
|
||||
}
|
||||
|
||||
iteration++;
|
||||
|
||||
// Check for stop every 100 iterations, but only send progress updates every 200ms
|
||||
if (iteration % 100 === 0) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastProgress = now - this.stats.lastProgressTime;
|
||||
|
||||
if (timeSinceLastProgress >= 200) {
|
||||
const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations));
|
||||
|
||||
self.postMessage({
|
||||
type: 'progress',
|
||||
data: {
|
||||
iteration,
|
||||
maxIterations: Math.min(totalCombinations, this.maxIterations),
|
||||
bestScore: this.bestScore,
|
||||
currentBest: this.bestSolution,
|
||||
totalCombinations,
|
||||
evaluatedCombinations: iteration,
|
||||
...stats
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (iteration >= this.maxIterations) {
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
|
||||
returnBestResult(iteration, totalCombinations) {
|
||||
if (!this.bestSolution) {
|
||||
// No solution found yet, create one from first cells
|
||||
const config = [];
|
||||
for (let i = 0; i < this.serial; i++) {
|
||||
config.push(this.cells.slice(i * this.parallel, (i + 1) * this.parallel));
|
||||
}
|
||||
const scoreResult = calculateScore(config, this.capacityWeight, this.irWeight);
|
||||
this.bestSolution = { config, ...scoreResult };
|
||||
this.bestScore = scoreResult.score;
|
||||
}
|
||||
|
||||
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
|
||||
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||
|
||||
// Final progress update
|
||||
const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations));
|
||||
self.postMessage({
|
||||
type: 'progress',
|
||||
data: {
|
||||
iteration,
|
||||
maxIterations: Math.min(totalCombinations, this.maxIterations),
|
||||
bestScore: this.bestScore,
|
||||
currentBest: this.bestSolution,
|
||||
totalCombinations,
|
||||
evaluatedCombinations: iteration,
|
||||
...stats
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
configuration: this.bestSolution.config,
|
||||
score: this.bestScore,
|
||||
capacityCV: this.bestSolution.capacityCV,
|
||||
irCV: this.bestSolution.irCV,
|
||||
groupCapacities: this.bestSolution.groupCapacities,
|
||||
excludedCells,
|
||||
iterations: iteration,
|
||||
elapsedTime: Date.now() - this.stats.startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Worker Message Handler
|
||||
// =============================================================================
|
||||
|
||||
let currentAlgorithm = null;
|
||||
|
||||
self.onmessage = function (e) {
|
||||
const { type, data } = e.data;
|
||||
|
||||
switch (type) {
|
||||
case 'start':
|
||||
const { cells, serial, parallel, algorithm, options } = data;
|
||||
|
||||
switch (algorithm) {
|
||||
case 'exhaustive':
|
||||
currentAlgorithm = new ExhaustiveSearch(cells, serial, parallel, options);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = currentAlgorithm.run();
|
||||
self.postMessage({ type: 'complete', data: result });
|
||||
} catch (error) {
|
||||
self.postMessage({ type: 'error', data: error.message });
|
||||
}
|
||||
|
||||
currentAlgorithm = null;
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
console.log("Algo: Stop requested")
|
||||
if (currentAlgorithm) {
|
||||
currentAlgorithm.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user