730 lines
24 KiB
JavaScript
730 lines
24 KiB
JavaScript
/**
|
|
* LiXX Cell Pack Matcher - Main Application
|
|
*
|
|
* A web application for optimal matching of lithium battery cells.
|
|
* Supports capacity and internal resistance matching with multiple algorithms.
|
|
*/
|
|
|
|
// =============================================================================
|
|
// Application State
|
|
// =============================================================================
|
|
|
|
const AppState = {
|
|
cells: [],
|
|
cellIdCounter: 0,
|
|
currentAlgorithm: null,
|
|
isRunning: false,
|
|
results: null
|
|
};
|
|
|
|
// =============================================================================
|
|
// DOM Elements
|
|
// =============================================================================
|
|
|
|
const DOM = {
|
|
// Configuration
|
|
cellsSerial: document.getElementById('cells-serial'),
|
|
cellsParallel: document.getElementById('cells-parallel'),
|
|
configDisplay: document.getElementById('config-display'),
|
|
totalCellsNeeded: document.getElementById('total-cells-needed'),
|
|
|
|
// Cell input
|
|
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'),
|
|
|
|
// Settings
|
|
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'),
|
|
|
|
// Matching
|
|
btnStartMatching: document.getElementById('btn-start-matching'),
|
|
btnStopMatching: document.getElementById('btn-stop-matching'),
|
|
|
|
// Progress
|
|
progressSection: document.getElementById('progress-section'),
|
|
progressFill: document.getElementById('progress-fill'),
|
|
progressIteration: document.getElementById('progress-iteration'),
|
|
progressScore: document.getElementById('progress-score'),
|
|
progressTime: document.getElementById('progress-time'),
|
|
|
|
// Results
|
|
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'),
|
|
|
|
// Export
|
|
btnExportJson: document.getElementById('btn-export-json'),
|
|
btnExportCsv: document.getElementById('btn-export-csv'),
|
|
btnCopyResults: document.getElementById('btn-copy-results'),
|
|
|
|
// Dialog
|
|
shortcutsDialog: document.getElementById('shortcuts-dialog'),
|
|
btnCloseShortcuts: document.getElementById('btn-close-shortcuts')
|
|
};
|
|
|
|
// =============================================================================
|
|
// Configuration Management
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Update the configuration display.
|
|
*/
|
|
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
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Add a new cell row to the table.
|
|
* @param {Object} cellData - Optional initial data
|
|
*/
|
|
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);
|
|
|
|
// Add event listeners
|
|
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();
|
|
|
|
// Focus the capacity input of the new row
|
|
if (!cellData) {
|
|
row.querySelector('input[data-field="capacity"]').focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update cell data when input changes.
|
|
*/
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Remove a cell from the table.
|
|
*/
|
|
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();
|
|
|
|
// Update row numbers
|
|
DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => {
|
|
row.querySelector('td:first-child').textContent = idx + 1;
|
|
});
|
|
|
|
updateCellStats();
|
|
updateMatchingButtonState();
|
|
}
|
|
|
|
/**
|
|
* Clear all cells.
|
|
*/
|
|
function clearAllCells() {
|
|
if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return;
|
|
|
|
AppState.cells = [];
|
|
AppState.cellIdCounter = 0;
|
|
DOM.cellTbody.innerHTML = '';
|
|
|
|
updateCellStats();
|
|
updateMatchingButtonState();
|
|
}
|
|
|
|
/**
|
|
* Update cell statistics display.
|
|
*/
|
|
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);
|
|
|
|
if (capacities.length > 0) {
|
|
const avgCap = capacities.reduce((a, b) => a + b, 0) / capacities.length;
|
|
DOM.statAvgCap.textContent = `${Math.round(avgCap)} mAh`;
|
|
} else {
|
|
DOM.statAvgCap.textContent = '-';
|
|
}
|
|
|
|
if (irs.length > 0) {
|
|
const avgIr = irs.reduce((a, b) => a + b, 0) / irs.length;
|
|
DOM.statAvgIr.textContent = `${avgIr.toFixed(1)} mΩ`;
|
|
} else {
|
|
DOM.statAvgIr.textContent = '-';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load example cell data.
|
|
*/
|
|
function loadExampleData() {
|
|
if (AppState.cells.length > 0 && !confirm('Replace current cells with example data?')) return;
|
|
|
|
// Clear without confirmation since we just asked
|
|
AppState.cells = [];
|
|
AppState.cellIdCounter = 0;
|
|
DOM.cellTbody.innerHTML = '';
|
|
|
|
// Example: 14 cells for a 6S2P pack (2 spare)
|
|
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 }, // Spare - lower quality
|
|
{ label: 'B14', capacity: 3380, ir: 40 } // Spare
|
|
];
|
|
|
|
exampleCells.forEach(cell => addCell(cell));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Weight Sliders
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Update weight slider displays and keep them summing to 100%.
|
|
*/
|
|
function updateWeights(source) {
|
|
const capWeight = parseInt(DOM.weightCapacity.value);
|
|
const irWeight = parseInt(DOM.weightIr.value);
|
|
|
|
if (source === 'capacity') {
|
|
DOM.weightIr.value = 100 - capWeight;
|
|
} else {
|
|
DOM.weightCapacity.value = 100 - irWeight;
|
|
}
|
|
|
|
DOM.weightCapValue.textContent = `${DOM.weightCapacity.value}%`;
|
|
DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Matching Control
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Update the state of the matching button.
|
|
*/
|
|
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;
|
|
|
|
if (validCells.length < needed) {
|
|
DOM.btnStartMatching.title = `Need at least ${needed} cells with capacity data`;
|
|
} else {
|
|
DOM.btnStartMatching.title = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the matching process.
|
|
*/
|
|
async 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;
|
|
|
|
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;
|
|
|
|
const options = {
|
|
maxIterations,
|
|
capacityWeight,
|
|
irWeight,
|
|
onProgress: updateProgress
|
|
};
|
|
|
|
// Create algorithm instance
|
|
const { GeneticAlgorithm, SimulatedAnnealing, ExhaustiveSearch } = window.CellMatchingAlgorithms;
|
|
|
|
switch (algorithmType) {
|
|
case 'genetic':
|
|
AppState.currentAlgorithm = new GeneticAlgorithm(validCells, serial, parallel, options);
|
|
break;
|
|
case 'simulated-annealing':
|
|
AppState.currentAlgorithm = new SimulatedAnnealing(validCells, serial, parallel, options);
|
|
break;
|
|
case 'exhaustive':
|
|
AppState.currentAlgorithm = new ExhaustiveSearch(validCells, serial, parallel, options);
|
|
break;
|
|
}
|
|
|
|
try {
|
|
const results = await AppState.currentAlgorithm.run();
|
|
AppState.results = results;
|
|
displayResults(results);
|
|
} catch (error) {
|
|
console.error('Matching error:', error);
|
|
alert('An error occurred during matching. See console for details.');
|
|
} finally {
|
|
AppState.isRunning = false;
|
|
AppState.currentAlgorithm = null;
|
|
DOM.progressSection.hidden = true;
|
|
DOM.btnStartMatching.disabled = false;
|
|
updateMatchingButtonState();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the matching process.
|
|
*/
|
|
function stopMatching() {
|
|
if (AppState.currentAlgorithm) {
|
|
AppState.currentAlgorithm.stop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update progress display.
|
|
*/
|
|
function updateProgress(progress) {
|
|
const percent = (progress.iteration / progress.maxIterations) * 100;
|
|
DOM.progressFill.style.width = `${percent}%`;
|
|
DOM.progressIteration.textContent = progress.iteration.toLocaleString();
|
|
DOM.progressScore.textContent = progress.bestScore.toFixed(4);
|
|
DOM.progressTime.textContent = `${(progress.elapsedTime / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Results Display
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Display the matching results.
|
|
*/
|
|
function displayResults(results) {
|
|
DOM.resultsSection.hidden = false;
|
|
|
|
// Summary metrics
|
|
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';
|
|
|
|
// Calculate pack capacity (limited by smallest parallel group)
|
|
const packCapacity = Math.min(...results.groupCapacities);
|
|
DOM.resultPackCapacity.textContent = `${packCapacity} mAh`;
|
|
|
|
// Visualize pack layout
|
|
renderPackVisualization(results);
|
|
|
|
// Results table
|
|
renderResultsTable(results);
|
|
|
|
// Excluded cells
|
|
if (results.excludedCells.length > 0) {
|
|
DOM.excludedCellsSection.hidden = false;
|
|
DOM.excludedCellsList.textContent = results.excludedCells.map(c => c.label).join(', ');
|
|
} else {
|
|
DOM.excludedCellsSection.hidden = true;
|
|
}
|
|
|
|
// Scroll to results
|
|
DOM.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
/**
|
|
* Render the pack visualization.
|
|
*/
|
|
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';
|
|
|
|
// Color based on relative capacity
|
|
const normalized = (cell.capacity - minCap) / range;
|
|
const hue = normalized * 120; // 0 = red, 60 = yellow, 120 = green
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render the results table.
|
|
*/
|
|
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
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Export results as JSON.
|
|
*/
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* Export results as CSV.
|
|
*/
|
|
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('');
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* Copy results to clipboard.
|
|
*/
|
|
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('');
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function to download a file.
|
|
*/
|
|
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
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Setup keyboard shortcuts.
|
|
*/
|
|
function setupKeyboardShortcuts() {
|
|
document.addEventListener('keydown', (e) => {
|
|
// Alt + A: Add cell
|
|
if (e.altKey && e.key === 'a') {
|
|
e.preventDefault();
|
|
addCell();
|
|
}
|
|
|
|
// Alt + S: Start matching
|
|
if (e.altKey && e.key === 's') {
|
|
e.preventDefault();
|
|
if (!DOM.btnStartMatching.disabled) {
|
|
startMatching();
|
|
}
|
|
}
|
|
|
|
// Alt + E: Load example
|
|
if (e.altKey && e.key === 'e') {
|
|
e.preventDefault();
|
|
loadExampleData();
|
|
}
|
|
|
|
// Escape: Stop matching or close dialog
|
|
if (e.key === 'Escape') {
|
|
if (AppState.isRunning) {
|
|
stopMatching();
|
|
}
|
|
if (DOM.shortcutsDialog.open) {
|
|
DOM.shortcutsDialog.close();
|
|
}
|
|
}
|
|
|
|
// ?: Show shortcuts
|
|
if (e.key === '?' && !e.target.matches('input, textarea')) {
|
|
e.preventDefault();
|
|
DOM.shortcutsDialog.showModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Event Listeners
|
|
// =============================================================================
|
|
|
|
function initEventListeners() {
|
|
// Configuration
|
|
DOM.cellsSerial.addEventListener('input', updateConfigDisplay);
|
|
DOM.cellsParallel.addEventListener('input', updateConfigDisplay);
|
|
|
|
// Cell management
|
|
DOM.btnAddCell.addEventListener('click', () => addCell());
|
|
DOM.btnLoadExample.addEventListener('click', loadExampleData);
|
|
DOM.btnClearAll.addEventListener('click', clearAllCells);
|
|
|
|
// Weight sliders
|
|
DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity'));
|
|
DOM.weightIr.addEventListener('input', () => updateWeights('ir'));
|
|
|
|
// Matching
|
|
DOM.btnStartMatching.addEventListener('click', startMatching);
|
|
DOM.btnStopMatching.addEventListener('click', stopMatching);
|
|
|
|
// Export
|
|
DOM.btnExportJson.addEventListener('click', exportJson);
|
|
DOM.btnExportCsv.addEventListener('click', exportCsv);
|
|
DOM.btnCopyResults.addEventListener('click', copyResults);
|
|
|
|
// Dialog
|
|
DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close());
|
|
}
|
|
|
|
// =============================================================================
|
|
// Initialization
|
|
// =============================================================================
|
|
|
|
function init() {
|
|
initEventListeners();
|
|
setupKeyboardShortcuts();
|
|
updateConfigDisplay();
|
|
updateWeights('capacity');
|
|
updateMatchingButtonState();
|
|
|
|
// Add a few empty cell rows to start
|
|
for (let i = 0; i < 3; i++) {
|
|
addCell();
|
|
}
|
|
}
|
|
|
|
// Start the application when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|