import { canvasState } from './state.js';
import { initOC } from './oc.js';
import { toggleBmsDiameter, initCustomSelects, showStatus } from './ui.js';
import { drawPreview } from './preview.js';
import { updatePreview, generateLayout, redrawBusbarOverlay, downloadSingleBusbar, downloadAllBusbarsZip, setOrderUpdateCallback, refreshOrderFromLastState } from './app.js';
import { busbarStore } from './busbars.js';
import { initBusbarUI, renderBusbarList } from './busbar-ui.js';
import { captureConfig, encodeConfigToHash, decodeHashToConfig } from './url-config.js';
import { renderOrderSection } from './order.js';
const CLICK_PIXEL_THRESHOLD = 4;
const URL_SYNC_DEBOUNCE_MS = 250;
let packModeController = null;
let urlSyncTimer = null;
let isApplyingUrlConfig = false;
function scaleCanvasById(id) {
const canvas = document.getElementById(id);
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
// Only update the drawing buffer to match the current CSS-rendered size × DPR.
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
}
function scaleCanvasForDPI() {
scaleCanvasById('preview');
scaleCanvasById('preview-bottom');
}
function getPackMode() {
const el = document.querySelector('[data-pack-mode]');
return (el && el.dataset.mode) || 'sp';
}
function refreshCustomSelect(id) {
const select = document.getElementById(id);
if (!select) return;
const wrapper = select.closest('.custom-select');
if (!wrapper) return;
const selected = wrapper.querySelector('.select-selected');
const items = wrapper.querySelectorAll('.select-items div');
if (selected) {
selected.textContent = select.options[select.selectedIndex]?.text || '';
}
items.forEach((item) => {
item.classList.toggle('same-as-selected', item.dataset.value === select.value);
});
}
function setNumberInput(id, value) {
const input = document.getElementById(id);
if (!input) return;
input.value = String(value);
}
function setCheckboxInput(id, value) {
const input = document.getElementById(id);
if (!input) return;
input.checked = !!value;
}
function setSelectInput(id, value) {
const select = document.getElementById(id);
if (!select) return;
select.value = String(value);
refreshCustomSelect(id);
}
function setPackMode(mode, options = {}) {
const { clearBusbars = true, refresh = true } = options;
if (!packModeController) return;
const nextMode = mode === 'mm' ? 'mm' : 'sp';
const { toggle, buttons, indicator, spFields, mmFields } = packModeController;
toggle.dataset.mode = nextMode;
buttons.forEach((b) => {
const on = b.dataset.mode === nextMode;
b.classList.toggle('active', on);
if (on && indicator) {
indicator.style.left = b.offsetLeft + 'px';
indicator.style.width = b.offsetWidth + 'px';
}
});
if (spFields) spFields.hidden = nextMode !== 'sp';
if (mmFields) mmFields.hidden = nextMode !== 'mm';
if (refresh) {
syncPackDimsFromSP();
updatePreview(true);
}
if (clearBusbars) {
busbarStore.clearAll();
}
}
async function syncUrlHashNow() {
if (isApplyingUrlConfig) return;
try {
const config = captureConfig(() => getPackMode(), busbarStore.getSnapshot());
const hash = await encodeConfigToHash(config);
if (window.location.hash !== hash) {
window.history.replaceState(null, '', hash);
}
} catch (error) {
console.error('Failed to sync URL hash:', error);
}
}
function scheduleUrlHashSync() {
if (isApplyingUrlConfig) return;
if (urlSyncTimer) {
clearTimeout(urlSyncTimer);
}
urlSyncTimer = setTimeout(() => {
urlSyncTimer = null;
void syncUrlHashNow();
}, URL_SYNC_DEBOUNCE_MS);
}
function applyConfigToUi(config) {
isApplyingUrlConfig = true;
try {
setNumberInput('series', config.pack.series);
setNumberInput('parallel', config.pack.parallel);
setNumberInput('xDim', config.pack.xDim);
setNumberInput('yDim', config.pack.yDim);
setNumberInput('cellSize', config.cell.cellSize);
setSelectInput('layoutType', config.cell.layoutType);
setNumberInput('spacing', config.cell.spacing);
setNumberInput('height', config.cell.height);
setNumberInput('coverThickness', config.cell.coverThickness);
setNumberInput('ledgeWidth', config.cell.ledgeWidth);
setCheckboxInput('roundedCorners', config.cell.roundedCorners);
setSelectInput('bmsHolesType', config.bms.type);
setNumberInput('bmsHoleDiameter', config.bms.holeDiameter);
setNumberInput('tabWidth', config.bms.tabWidth);
setNumberInput('tabLength', config.bms.tabLength ?? 10.0);
setSelectInput('tabOverlapSide', config.bms.tabOverlapSide || 'off');
setSelectInput('busbarFormat', config.busbars.format);
setCheckboxInput('busbarCellCutoutEnabled', config.busbars.cellCutoutEnabled === true);
setPackMode(config.pack.mode, { clearBusbars: false, refresh: false });
toggleBmsDiameter();
syncPackDimsFromSP();
if (config.pack.mode === 'mm') {
setNumberInput('xDim', config.pack.xDim);
setNumberInput('yDim', config.pack.yDim);
}
busbarStore.replaceFromSnapshot({
activeId: config.busbars.activeId,
list: config.busbars.list,
});
renderBusbarList();
} finally {
isApplyingUrlConfig = false;
}
}
async function loadConfigFromUrl() {
if (!window.location.hash || !window.location.hash.startsWith('#config=')) {
return false;
}
const decoded = await decodeHashToConfig(window.location.hash);
if (!decoded.ok) {
showStatus('Shared URL is invalid or corrupted. Loaded default configuration.', 'error');
return false;
}
applyConfigToUi(decoded.config);
return true;
}
function wireShareButton() {
const button = document.getElementById('copyShareUrlBtn');
if (!button) return;
const defaultLabel = (button.textContent || 'Copy Share URL').trim();
let resetTimer = null;
const setTemporaryButtonState = (stateClass, text) => {
if (resetTimer) clearTimeout(resetTimer);
button.classList.remove('is-success', 'is-error');
if (stateClass) button.classList.add(stateClass);
button.textContent = text;
resetTimer = setTimeout(() => {
button.classList.remove('is-success', 'is-error');
button.textContent = defaultLabel;
}, 2000);
};
button.addEventListener('click', async () => {
button.disabled = true;
try {
await syncUrlHashNow();
const shareUrl = `${window.location.origin}${window.location.pathname}${window.location.hash}`;
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API unavailable');
}
await navigator.clipboard.writeText(shareUrl);
setTemporaryButtonState('is-success', '✓ Copied');
} catch (error) {
console.error('Failed to copy share URL:', error);
setTemporaryButtonState('is-error', 'Copy failed');
} finally {
button.disabled = false;
}
});
}
function wireUrlSyncListeners() {
const ids = [
'series', 'parallel', 'xDim', 'yDim', 'height',
'cellSize', 'layoutType', 'spacing', 'coverThickness', 'ledgeWidth',
'roundedCorners', 'bmsHolesType', 'bmsHoleDiameter', 'tabWidth',
'tabOverlapSide', 'busbarFormat', 'busbarCellCutoutEnabled',
];
ids.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', scheduleUrlHashSync);
el.addEventListener('change', scheduleUrlHashSync);
});
busbarStore.subscribeMutations(scheduleUrlHashSync);
}
// Compute the pack footprint (world mm) from Series and Parallel counts and write
// it to the xDim / yDim inputs so the existing layout generators pick it up. When
// the user switches to "Size (mm)" mode, xDim / yDim are entered directly and this
// routine just refreshes the summary.
function syncPackDimsFromSP() {
const mode = getPackMode();
const xEl = document.getElementById('xDim');
const yEl = document.getElementById('yDim');
const summary = document.getElementById('packSummary');
if (mode === 'mm') {
const xDim = parseFloat(xEl.value) || 0;
const yDim = parseFloat(yEl.value) || 0;
if (summary) {
summary.innerHTML =
`${xDim.toFixed(0)} × ${yDim.toFixed(0)} mm ` +
`footprint. Cells fit automatically.`;
}
return;
}
const s = Math.max(1, Math.round(parseFloat(document.getElementById('series').value) || 1));
const p = Math.max(1, Math.round(parseFloat(document.getElementById('parallel').value) || 1));
const cellSize = parseFloat(document.getElementById('cellSize').value) || 21.35;
const spacing = parseFloat(document.getElementById('spacing').value) || 0.6;
const layoutType = document.getElementById('layoutType').value;
const gridStride = cellSize + spacing;
const hexStride = Math.sqrt(3) / 2 * gridStride;
const EPS = 0.02;
const gridSpan = (n) => cellSize + 2 * spacing + (n - 1) * gridStride + EPS;
const hexSpan = (n) => cellSize + 2 * spacing + (n - 1) * hexStride + EPS;
// Offset packing: size for the shifted row so both rows fit n cells.
const offsetSpan = (n) => cellSize + 2 * spacing + (n - 1) * gridStride + gridStride / 2 + EPS;
let xDim, yDim;
if (layoutType === 'vertical') {
xDim = hexSpan(s);
yDim = offsetSpan(p);
} else if (layoutType === 'honeycomb') {
xDim = offsetSpan(s);
yDim = hexSpan(p);
} else {
xDim = gridSpan(s);
yDim = gridSpan(p);
}
xEl.value = xDim.toFixed(2);
yEl.value = yDim.toFixed(2);
if (summary) {
const total = s * p;
summary.innerHTML =
`${s}S ${p}P. ${total} cells. ` +
`Footprint about ${xDim.toFixed(0)} × ${yDim.toFixed(0)} mm.`;
}
}
function wireInputs() {
// Series/Parallel drive xDim/yDim in SP mode. When the user types into them (or
// into spacing/cellSize/layoutType) we resync and re-render.
const spInputs = ['series', 'parallel'];
spInputs.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', () => { syncPackDimsFromSP(); updatePreview(true); });
el.addEventListener('change', () => busbarStore.clearAll());
});
// In mm mode xDim / yDim are user inputs; refresh summary and preview directly.
const mmInputs = ['xDim', 'yDim'];
mmInputs.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', () => {
if (getPackMode() === 'mm') {
syncPackDimsFromSP();
updatePreview(true);
}
});
el.addEventListener('change', () => {
if (getPackMode() === 'mm') busbarStore.clearAll();
});
});
const layoutInputs = ['spacing', 'cellSize', 'layoutType'];
layoutInputs.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', () => busbarStore.clearAll());
});
const dimensionInputs = ['spacing', 'cellSize', 'layoutType', 'height', 'coverThickness'];
dimensionInputs.forEach(id => {
const element = document.getElementById(id);
if (!element) return;
const handler = () => { syncPackDimsFromSP(); updatePreview(true); };
element.addEventListener('input', handler);
element.addEventListener('change', handler);
});
const visualInputs = ['bmsHolesType', 'roundedCorners', 'bmsHoleDiameter', 'ledgeWidth', 'tabWidth', 'tabLength', 'tabOverlapSide', 'busbarCellCutoutEnabled'];
visualInputs.forEach(id => {
const element = document.getElementById(id);
if (!element) return;
element.addEventListener('input', () => updatePreview(false));
element.addEventListener('change', () => updatePreview(false));
});
}
function wirePackMode() {
const toggle = document.querySelector('[data-pack-mode]');
if (!toggle) return;
const buttons = Array.from(toggle.querySelectorAll('.seg'));
const indicator = toggle.querySelector('.seg-indicator');
const spFields = document.querySelector('.pack-sp-fields');
const mmFields = document.querySelector('.pack-mm-fields');
const moveIndicator = (btn) => {
if (!indicator || !btn) return;
indicator.style.left = btn.offsetLeft + 'px';
indicator.style.width = btn.offsetWidth + 'px';
};
packModeController = { toggle, buttons, indicator, spFields, mmFields };
buttons.forEach(b => b.addEventListener('click', () => setPackMode(b.dataset.mode)));
requestAnimationFrame(() => {
const active = buttons.find(b => b.classList.contains('active')) || buttons[0];
if (active) moveIndicator(active);
});
}
function redrawFromState() {
if (canvasState.currentPositions.length > 0) {
redrawBusbarOverlay();
}
}
function canvasPointToWorld(cx, cy) {
const t = canvasState.viewTransform;
if (!t) return null;
const localX = (cx - canvasState.panX) / canvasState.zoom;
const localY = (cy - canvasState.panY) / canvasState.zoom;
const worldX = (localX - t.offsetX) / t.scale + t.minX - t.r - t.spacing;
const worldY = (localY - t.offsetY) / t.scale + t.minY - t.r - t.spacing;
return [worldX, worldY];
}
function handleCanvasClick(cx, cy) {
const active = busbarStore.getActive();
if (!active) return;
const world = canvasPointToWorld(cx, cy);
if (!world) return;
const cellRadius = canvasState.currentCellSize / 2;
let bestIdx = -1, bestDist = cellRadius;
canvasState.currentPositions.forEach(([x, y], i) => {
const d = Math.hypot(world[0] - x, world[1] - y);
if (d < bestDist) { bestDist = d; bestIdx = i; }
});
if (bestIdx >= 0) busbarStore.toggleCell(bestIdx);
}
function wireCanvasInteractions() {
// Wire both canvases with identical pan/zoom/click behaviour.
// They share canvasState so moving one moves both.
// When a canvas for a given face is focused, auto-switch to a busbar of that face.
function switchActiveToFace(face) {
const active = busbarStore.getActive();
if (active && (active.face || 'top') !== face) {
const match = busbarStore.list.find(b => (b.face || 'top') === face);
if (match) {
busbarStore.setActive(match.id);
}
}
}
function wirePanZoom(el, face) {
if (!el) return;
el.style.cursor = 'grab';
// ── Wheel zoom ────────────────────────────────────────────────────────
el.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomSpeed = 0.1;
const delta = e.deltaY > 0 ? -zoomSpeed : zoomSpeed;
const newZoom = Math.max(0.2, Math.min(5.0, canvasState.zoom + delta));
const rect = el.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const zoomRatio = newZoom / canvasState.zoom;
canvasState.panX = mouseX - (mouseX - canvasState.panX) * zoomRatio;
canvasState.panY = mouseY - (mouseY - canvasState.panY) * zoomRatio;
canvasState.zoom = newZoom;
redrawFromState();
}, { passive: false });
// ── Mouse drag ────────────────────────────────────────────────────────
el.addEventListener('mousedown', (e) => {
switchActiveToFace(face);
canvasState.isDragging = true;
canvasState.dragStartX = e.clientX;
canvasState.dragStartY = e.clientY;
canvasState.dragMoved = false;
canvasState.lastMouseX = e.clientX;
canvasState.lastMouseY = e.clientY;
el.style.cursor = 'grabbing';
});
el.addEventListener('mousemove', (e) => {
if (!canvasState.isDragging) return;
const totalDx = e.clientX - canvasState.dragStartX;
const totalDy = e.clientY - canvasState.dragStartY;
if (Math.abs(totalDx) > CLICK_PIXEL_THRESHOLD || Math.abs(totalDy) > CLICK_PIXEL_THRESHOLD) {
canvasState.dragMoved = true;
}
canvasState.panX += e.clientX - canvasState.lastMouseX;
canvasState.panY += e.clientY - canvasState.lastMouseY;
canvasState.lastMouseX = e.clientX;
canvasState.lastMouseY = e.clientY;
if (canvasState.currentPositions.length > 0) {
requestAnimationFrame(() => redrawFromState());
}
});
el.addEventListener('mouseup', (e) => {
if (canvasState.isDragging && !canvasState.dragMoved) {
const rect = el.getBoundingClientRect();
handleCanvasClick(e.clientX - rect.left, e.clientY - rect.top);
}
canvasState.isDragging = false;
canvasState.dragMoved = false;
el.style.cursor = 'grab';
});
el.addEventListener('mouseleave', () => {
// Only reset cursor, NOT isDragging — user may move to the other canvas.
el.style.cursor = 'grab';
});
// ── Touch ─────────────────────────────────────────────────────────────
let touchStartDistance = 0;
let touchStartZoom = 1.0;
let touchStartPanX = 0;
let touchStartPanY = 0;
let touchCenterX = 0;
let touchCenterY = 0;
let lastTouchX = 0;
let lastTouchY = 0;
let touchStartClientX = 0;
let touchStartClientY = 0;
let touchMoved = false;
let isTouching = false;
el.addEventListener('touchstart', (e) => {
e.preventDefault();
switchActiveToFace(face);
if (e.touches.length === 1) {
isTouching = true;
lastTouchX = e.touches[0].clientX;
lastTouchY = e.touches[0].clientY;
touchStartClientX = lastTouchX;
touchStartClientY = lastTouchY;
touchMoved = false;
} else if (e.touches.length === 2) {
isTouching = false;
const t1 = e.touches[0];
const t2 = e.touches[1];
touchStartDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
touchStartZoom = canvasState.zoom;
touchStartPanX = canvasState.panX;
touchStartPanY = canvasState.panY;
const rect = el.getBoundingClientRect();
touchCenterX = ((t1.clientX + t2.clientX) / 2) - rect.left;
touchCenterY = ((t1.clientY + t2.clientY) / 2) - rect.top;
}
}, { passive: false });
el.addEventListener('touchmove', (e) => {
e.preventDefault();
if (e.touches.length === 1 && isTouching) {
const touch = e.touches[0];
const totalDx = touch.clientX - touchStartClientX;
const totalDy = touch.clientY - touchStartClientY;
if (Math.abs(totalDx) > CLICK_PIXEL_THRESHOLD || Math.abs(totalDy) > CLICK_PIXEL_THRESHOLD) {
touchMoved = true;
}
canvasState.panX += touch.clientX - lastTouchX;
canvasState.panY += touch.clientY - lastTouchY;
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
if (canvasState.currentPositions.length > 0) {
requestAnimationFrame(() => redrawFromState());
}
} else if (e.touches.length === 2) {
const t1 = e.touches[0];
const t2 = e.touches[1];
const currentDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const zoomRatio = currentDistance / touchStartDistance;
const newZoom = Math.max(0.2, Math.min(5.0, touchStartZoom * zoomRatio));
const zoomChange = newZoom / touchStartZoom;
canvasState.panX = touchCenterX - (touchCenterX - touchStartPanX) * zoomChange;
canvasState.panY = touchCenterY - (touchCenterY - touchStartPanY) * zoomChange;
canvasState.zoom = newZoom;
if (canvasState.currentPositions.length > 0) {
requestAnimationFrame(() => redrawFromState());
}
}
}, { passive: false });
el.addEventListener('touchend', (e) => {
e.preventDefault();
if (e.changedTouches.length > 0 && isTouching && !touchMoved) {
const t = e.changedTouches[0];
const rect = el.getBoundingClientRect();
handleCanvasClick(t.clientX - rect.left, t.clientY - rect.top);
}
if (e.touches.length === 0) { isTouching = false; touchMoved = false; }
if (e.touches.length < 2) touchStartDistance = 0;
}, { passive: false });
el.addEventListener('touchcancel', () => {
isTouching = false;
touchMoved = false;
touchStartDistance = 0;
});
}
// Global mouseup so releasing outside a canvas still ends the drag.
window.addEventListener('mouseup', () => {
if (canvasState.isDragging) {
canvasState.isDragging = false;
canvasState.dragMoved = false;
document.getElementById('preview')?.style && (document.getElementById('preview').style.cursor = 'grab');
document.getElementById('preview-bottom')?.style && (document.getElementById('preview-bottom').style.cursor = 'grab');
}
});
wirePanZoom(document.getElementById('preview'), 'top');
wirePanZoom(document.getElementById('preview-bottom'), 'bottom');
}
function wireSidebarTabs() {
const root = document.querySelector('[data-tabs]');
if (!root) return;
const tabs = Array.from(root.querySelectorAll('.tab'));
const indicator = root.querySelector('.tab-indicator');
const panels = Array.from(document.querySelectorAll('.tab-panel'));
const moveIndicator = (tab) => {
if (!indicator || !tab) return;
indicator.style.left = tab.offsetLeft + 'px';
indicator.style.width = tab.offsetWidth + 'px';
};
const activate = (key) => {
for (const tab of tabs) {
const on = tab.dataset.panel === key;
tab.classList.toggle('active', on);
tab.setAttribute('aria-selected', on ? 'true' : 'false');
if (on) moveIndicator(tab);
}
for (const panel of panels) {
panel.classList.toggle('active', panel.dataset.panel === key);
}
};
for (const tab of tabs) {
tab.addEventListener('click', () => activate(tab.dataset.panel));
}
// Set initial indicator position after layout settles.
requestAnimationFrame(() => {
const active = tabs.find(t => t.classList.contains('active')) || tabs[0];
if (active) moveIndicator(active);
});
window.addEventListener('resize', () => {
const active = tabs.find(t => t.classList.contains('active'));
if (active) moveIndicator(active);
});
}
async function initializeApp() {
scaleCanvasForDPI();
initCustomSelects();
setOrderUpdateCallback(renderOrderSection);
busbarStore.subscribe(() => refreshOrderFromLastState());
wireSidebarTabs();
wirePackMode();
const bmsTypeSelect = document.getElementById('bmsHolesType');
if (bmsTypeSelect) {
bmsTypeSelect.addEventListener('change', toggleBmsDiameter);
toggleBmsDiameter();
}
const generateBtn = document.getElementById('generateBtn');
if (generateBtn) {
generateBtn.addEventListener('click', generateLayout);
}
initBusbarUI({
onDownloadSingle: downloadSingleBusbar,
onDownloadAll: downloadAllBusbarsZip,
onFaceFilterChange(filter) {
// Double rAF: first frame applies the hidden/visible DOM change,
// second frame reads the settled flex layout dimensions.
requestAnimationFrame(() => requestAnimationFrame(() => {
scaleCanvasById('preview');
if (filter === 'both') scaleCanvasById('preview-bottom');
redrawBusbarOverlay();
}));
},
});
renderBusbarList();
busbarStore.subscribe(() => updatePreview(false));
wireInputs();
wireCanvasInteractions();
wireShareButton();
wireUrlSyncListeners();
const loadedFromUrl = await loadConfigFromUrl();
if (!loadedFromUrl) {
syncPackDimsFromSP();
}
await initOC();
// Keep canvas drawing buffers in sync whenever their CSS size changes
// (e.g. window resize, flex layout settling, face-filter toggles).
const canvasResizeObserver = new ResizeObserver(() => {
scaleCanvasForDPI();
updatePreview(false);
});
const previewCanvas = document.getElementById('preview');
const previewBottomCanvas = document.getElementById('preview-bottom');
if (previewCanvas) canvasResizeObserver.observe(previewCanvas);
if (previewBottomCanvas) canvasResizeObserver.observe(previewBottomCanvas);
window.addEventListener('resize', () => {
scaleCanvasForDPI();
updatePreview(false);
});
setTimeout(() => {
// Force a fresh render after both OC init and config load are complete.
// This ensures viewTransform and geometries are in sync with loaded busbars.
updatePreview(true);
void syncUrlHashNow();
}, 100);
}
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}