Initial commit: Busbar Designer

Web tool for designing nickel/copper busbars over cylindrical-cell battery
packs (21700, 18650) in hex holders. Flask + build123d backend exports
STEP/DXF/SVG; vanilla JS frontend with live preview, multi-project SQLite
persistence, snapshot history.

Deploy scripts in deploy/ (proxmox-lxc.sh, install.sh, update.sh).
This commit is contained in:
wenil
2026-05-24 18:59:50 +03:00
commit d8cb0dc06d
28 changed files with 4172 additions and 0 deletions
+666
View File
@@ -0,0 +1,666 @@
/* app.js — top-level controller.
*
* Persistence model:
* - Each "project" is a row in SQLite holding the full editor state
* (cells + busbars + params + activeBusbarId).
* - Active project id lives in the URL as ?p=<id>; bookmarking / opening
* in another browser reloads the same state.
* - Any onStateChanged() fires Viewport.render(), schedulePreview() and
* scheduleAutoSave(). Auto-save is debounced 1500 ms; the first save in
* each 60 s window also writes a snapshot (history).
* - If the user edits before naming a project, one is auto-created with a
* timestamp name; URL is updated.
*
* Presets are separate from projects: just a named blob of params.
*/
(() => {
// ---- state ---------------------------------------------------------------
const state = {
cells: [],
busbars: [],
cellToBusbar: new Map(),
selection: new Set(),
activeBusbarId: null,
};
const params = {
cellDia: 21.2,
openingDia: 15.2,
stripWidth: 6.0,
padRadius: 9.0,
holeRadius: 6.0,
holeShape: "cross",
slitWidth: 1.0,
neighborFactor: 1.15,
extrude: false,
thickness: 0.2,
};
let currentProject = null; // { id, name } when one is open
let isSaving = false;
let pendingSave = false;
let isDirty = false; // unsaved changes since the last successful save
let autoSaveTimer = null;
let lastSnapshotAt = 0;
const SNAPSHOT_INTERVAL_MS = 60_000;
let stepPreviewEnabled = true;
let stepPreviewTimer = null;
let stepPreviewInFlight = null;
// ---- helpers -------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const fmtMm = (v) => (Math.round(v * 100) / 100).toFixed(2);
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
function setSaveStatus(cls, text) {
const el = $("save-status");
if (!el) return;
el.className = "save-status " + (cls || "");
el.textContent = text || "—";
}
// ---- tabs ---------------------------------------------------------------
document.querySelectorAll(".tab").forEach((t) => {
t.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active"));
t.classList.add("active");
const target = t.dataset.tab;
document.querySelectorAll(".tab-body").forEach((b) => {
b.classList.toggle("hidden", b.dataset.tabBody !== target);
});
});
});
// ---- cell import --------------------------------------------------------
$("btn-import-paste").addEventListener("click", () => loadCells(Importer.parsePaste($("paste-text").value)));
$("btn-import-csv" ).addEventListener("click", () => loadCells(Importer.parseCSV ($("csv-text" ).value)));
$("btn-import-json" ).addEventListener("click", () => {
try { loadCells(Importer.parseJSON($("json-text").value)); }
catch (e) { alert(`JSON parse error: ${e.message}`); }
});
$("btn-import-gen").addEventListener("click", () => {
loadCells(Importer.generate({
cellDia: +$("gen-cell-dia").value,
wall: +$("gen-wall").value,
rows: +$("gen-rows").value,
cols: +$("gen-cols").value,
style: $("gen-style").value,
}));
});
// ---- param inputs -------------------------------------------------------
const paramInputs = [
["p-cell-dia", "cellDia"],
["p-opening-dia", "openingDia"],
["p-strip-width", "stripWidth"],
["p-pad-radius", "padRadius"],
["p-hole-radius", "holeRadius"],
["p-slit-width", "slitWidth"],
["p-neighbor-factor", "neighborFactor"],
["p-thickness", "thickness"],
];
for (const [id, key] of paramInputs) {
$(id).addEventListener("input", () => {
params[key] = +$(id).value;
onStateChanged();
});
}
$("p-hole-shape").addEventListener("change", () => {
params.holeShape = $("p-hole-shape").value;
onStateChanged();
});
$("p-extrude").addEventListener("change", () => {
params.extrude = $("p-extrude").checked;
schedulePreview();
scheduleAutoSave();
});
function _syncParamInputs() {
for (const [id, key] of paramInputs) $(id).value = params[key];
$("p-hole-shape").value = params.holeShape || "cross";
$("p-extrude").checked = !!params.extrude;
}
// ---- busbar create button -----------------------------------------------
$("btn-new-busbar").addEventListener("click", () => {
const ids = [...state.selection];
if (!ids.length) { alert("Select cells first."); return; }
const bb = Groups.create(state, ids);
state.activeBusbarId = bb.id;
state.selection.clear();
renderBusbarList();
updateSelInfo();
onStateChanged();
});
// ---- .json export / import (file-based; complements server storage) -----
$("btn-save").addEventListener("click", saveProjectAsFile);
$("btn-load").addEventListener("click", () => $("file-load").click());
$("file-load").addEventListener("change", (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try { applyState(JSON.parse(r.result)); onStateChanged(); }
catch (err) { alert(err.message); }
};
r.readAsText(f);
e.target.value = "";
});
// ---- export buttons -----------------------------------------------------
$("btn-export-step").addEventListener("click", () => Exporter.exportFormat("step", state, params));
$("btn-export-dxf" ).addEventListener("click", () => Exporter.exportFormat("dxf", state, params));
$("btn-export-svg" ).addEventListener("click", () => Exporter.exportFormat("svg", state, params));
// ---- viewport init ------------------------------------------------------
Viewport.init($("viewport"), state, params, {
onCellClick: (cellId, mods) => {
if (mods.alt) state.selection.delete(cellId);
else if (mods.shift) state.selection.add(cellId);
else { state.selection.clear(); state.selection.add(cellId); }
updateSelInfo();
Viewport.render();
},
onCursorMove: (x, y) => {
$("cursor-pos").textContent = `x: ${fmtMm(x)} , y: ${fmtMm(y)}`;
},
onZoomChange: (s) => {
$("zoom-info").textContent = `${fmtMm(s)} px/mm`;
},
});
// ---- project bar --------------------------------------------------------
$("project-select").addEventListener("change", (e) => {
const id = +e.target.value;
if (!id) return;
openProject(id);
});
$("project-name").addEventListener("change", async (e) => {
if (!currentProject) return;
const name = e.target.value.trim();
if (!name) { e.target.value = currentProject.name; return; }
try {
await Api.updateProject(currentProject.id, { name });
currentProject.name = name;
await refreshProjectList();
setSaveStatus("saved", "renamed");
setTimeout(() => setSaveStatus("", "—"), 1500);
} catch (err) { alert(err.message); }
});
$("btn-project-new").addEventListener("click", async () => {
const name = prompt(
"New project name:",
`Project ${new Date().toLocaleDateString()}`
);
if (name === null) return;
try {
const r = await Api.createProject(name.trim() || "Untitled", _emptyProjectData());
await refreshProjectList();
openProject(r.id);
} catch (e) { alert(e.message); }
});
$("btn-project-del").addEventListener("click", async () => {
if (!currentProject) return;
if (!confirm(`Delete project "${currentProject.name}"? This cannot be undone.`)) return;
try {
await Api.deleteProject(currentProject.id);
currentProject = null;
$("project-name").value = "";
history.replaceState({}, "", location.pathname);
await refreshProjectList();
applyState(_emptyProjectData());
} catch (e) { alert(e.message); }
});
// ---- history modal ------------------------------------------------------
$("btn-history").addEventListener("click", showHistory);
$("btn-history-close").addEventListener("click", () => $("history-modal").classList.add("hidden"));
$("history-modal").addEventListener("click", (e) => {
if (e.target.id === "history-modal") $("history-modal").classList.add("hidden");
});
async function showHistory() {
if (!currentProject) { alert("Open a project first."); return; }
let snaps;
try { snaps = await Api.listSnapshots(currentProject.id); }
catch (e) { alert(e.message); return; }
const ul = $("history-list");
if (!snaps.length) {
ul.innerHTML = '<li class="hint">No snapshots yet.</li>';
} else {
ul.innerHTML = snaps.map((s) => `
<li class="history-item">
<span class="time">${escapeHtml(s.created_at)}</span>
<span class="note">${escapeHtml(s.note || "(auto)")}</span>
<button data-id="${s.id}" class="restore">Restore</button>
</li>`).join("");
ul.querySelectorAll(".restore").forEach((b) => {
b.addEventListener("click", async () => {
if (!confirm("Restore this snapshot? The current state will be saved to history first.")) return;
try {
await Api.restoreSnapshot(+b.dataset.id);
$("history-modal").classList.add("hidden");
await openProject(currentProject.id);
} catch (e) { alert(e.message); }
});
});
}
$("history-modal").classList.remove("hidden");
}
// ---- presets ------------------------------------------------------------
$("btn-preset-apply").addEventListener("click", async () => {
const id = +$("preset-select").value;
if (!id) return;
try {
const p = await Api.getPreset?.(id) || (await Api.listPresets()).find((x) => x.id === id);
if (!p) return;
Object.assign(params, p.params);
_syncParamInputs();
onStateChanged();
} catch (e) { alert(e.message); }
});
$("btn-preset-save").addEventListener("click", async () => {
const name = prompt("Preset name (e.g. '21700 0.2mm Ni'):");
if (!name) return;
try {
await Api.createPreset(name.trim(), { ...params });
await refreshPresetList();
} catch (e) {
alert(e.message);
}
});
$("btn-preset-del").addEventListener("click", async () => {
const id = +$("preset-select").value;
if (!id) return;
const name = $("preset-select").selectedOptions[0]?.text || "";
if (!confirm(`Delete preset "${name}"?`)) return;
try {
await Api.deletePreset(id);
await refreshPresetList();
} catch (e) { alert(e.message); }
});
// ---- top-level orchestration --------------------------------------------
function _emptyProjectData() {
return { params: { ...params }, cells: [], busbars: [], activeBusbarId: null };
}
function loadCells(cells) {
if (!cells || !cells.length) { alert("No cells parsed."); return; }
state.cells = cells;
state.busbars = [];
state.cellToBusbar = new Map();
state.selection = new Set();
Groups.reset();
updateStatus();
renderBusbarList();
updateSelInfo();
Viewport.fitToContent();
Viewport.render();
schedulePreview();
// Save NOW (skip debounce) — most "I refreshed and lost everything"
// reports happen when the user refreshes within the 1.5 s window.
clearTimeout(autoSaveTimer);
performAutoSave();
}
function applyState(data) {
if (data.params) Object.assign(params, data.params);
state.cells = (data.cells || []).map((c, i) => ({ id: c.id ?? i + 1, x: +c.x, y: +c.y }));
Groups.reset();
state.busbars = (data.busbars || []).map((b) => ({
id: b.id,
name: b.name,
color: b.color,
shape: b.shape || "panel",
cells: [...(b.cells || [])],
}));
state.cellToBusbar = new Map();
for (const b of state.busbars) for (const cid of b.cells) state.cellToBusbar.set(cid, b.id);
state.selection = new Set();
state.activeBusbarId = data.activeBusbarId ?? null;
_syncParamInputs();
updateStatus();
renderBusbarList();
updateSelInfo();
Viewport.fitToContent();
schedulePreview();
}
function onStateChanged() {
Viewport.render();
schedulePreview();
scheduleAutoSave();
}
function updateStatus() {
const el = $("status");
if (!el) return;
el.textContent = state.cells.length
? `${state.cells.length} cells loaded`
: "No cells loaded";
}
function updateSelInfo() {
const el = $("sel-info");
if (!el) return;
el.textContent = `${state.selection.size} selected`;
}
// ---- busbar list --------------------------------------------------------
function renderBusbarList() {
const ul = $("busbar-list");
ul.innerHTML = "";
for (const bb of state.busbars) {
const li = document.createElement("li");
li.className = "busbar-item" + (bb.id === state.activeBusbarId ? " active" : "");
const swatch = document.createElement("input");
swatch.type = "color";
swatch.className = "busbar-color";
swatch.value = bb.color;
swatch.addEventListener("input", (e) => {
Groups.recolor(state, bb.id, e.target.value);
onStateChanged();
});
const name = document.createElement("input");
name.className = "busbar-name";
name.value = bb.name;
name.addEventListener("change", (e) => {
Groups.rename(state, bb.id, e.target.value);
scheduleAutoSave();
});
const count = document.createElement("span");
count.className = "busbar-count";
count.textContent = `${bb.cells.length}p`;
const shapeSel = document.createElement("select");
shapeSel.className = "busbar-shape";
shapeSel.title = "Shape: panel = production plate; wire = thin strip";
for (const opt of ["panel", "wire"]) {
const o = document.createElement("option");
o.value = opt; o.textContent = opt;
if (bb.shape === opt) o.selected = true;
shapeSel.appendChild(o);
}
shapeSel.addEventListener("change", (e) => {
bb.shape = e.target.value;
onStateChanged();
});
const actions = document.createElement("div");
actions.className = "busbar-actions";
const addBtn = document.createElement("button");
addBtn.textContent = "+ sel";
addBtn.addEventListener("click", () => {
Groups.addCells(state, bb.id, [...state.selection]);
state.selection.clear();
renderBusbarList(); updateSelInfo(); onStateChanged();
});
const remBtn = document.createElement("button");
remBtn.textContent = " sel";
remBtn.addEventListener("click", () => {
Groups.removeCells(state, bb.id, [...state.selection]);
state.selection.clear();
renderBusbarList(); updateSelInfo(); onStateChanged();
});
const delBtn = document.createElement("button");
delBtn.className = "del";
delBtn.textContent = "×";
delBtn.addEventListener("click", () => {
if (!confirm(`Delete busbar "${bb.name}"?`)) return;
Groups.remove(state, bb.id);
renderBusbarList(); onStateChanged();
});
actions.appendChild(addBtn); actions.appendChild(remBtn); actions.appendChild(delBtn);
li.appendChild(swatch); li.appendChild(name); li.appendChild(count);
li.appendChild(shapeSel); li.appendChild(actions);
li.addEventListener("click", (e) => {
if (e.target !== li) return;
state.activeBusbarId = bb.id;
renderBusbarList();
});
ul.appendChild(li);
}
}
// ---- .json file save (download) -----------------------------------------
function saveProjectAsFile() {
const blob = new Blob([JSON.stringify(_serialize(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (currentProject ? currentProject.name : "busbar-project") + ".json";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
function _serialize() {
return {
params,
cells: state.cells,
busbars: state.busbars,
activeBusbarId: state.activeBusbarId,
};
}
// ---- server: projects ---------------------------------------------------
async function refreshProjectList() {
try {
const list = await Api.listProjects();
const sel = $("project-select");
const keep = sel.value;
sel.innerHTML = '<option value="">— select project —</option>' +
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
if (currentProject) sel.value = String(currentProject.id);
else sel.value = keep;
} catch (e) {
console.error("refreshProjectList:", e);
}
}
async function openProject(id) {
try {
const p = await Api.getProject(id);
currentProject = { id: p.id, name: p.name };
lastSnapshotAt = 0;
$("project-name").value = p.name;
$("project-select").value = String(id);
history.replaceState({}, "", `?p=${id}`);
applyState(p.data || {});
setSaveStatus("saved", "loaded");
setTimeout(() => setSaveStatus("", "—"), 1500);
} catch (e) {
alert(`Open project failed: ${e.message}`);
}
}
async function ensureProject() {
if (currentProject) return;
const name = `Untitled ${new Date().toLocaleString()}`;
try {
const r = await Api.createProject(name, _serialize());
currentProject = { id: r.id, name: r.name };
$("project-name").value = r.name;
history.replaceState({}, "", `?p=${r.id}`);
await refreshProjectList();
} catch (e) {
console.error("ensureProject:", e);
}
}
// ---- auto-save ----------------------------------------------------------
function scheduleAutoSave() {
isDirty = true;
setSaveStatus("dirty", "● unsaved");
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(performAutoSave, 1500);
}
async function performAutoSave() {
await ensureProject();
if (!currentProject) return;
if (isSaving) { pendingSave = true; return; }
isSaving = true;
setSaveStatus("saving", "saving…");
try {
const now = Date.now();
const shouldSnapshot = now - lastSnapshotAt > SNAPSHOT_INTERVAL_MS;
await Api.updateProject(currentProject.id, {
data: _serialize(),
snapshot: shouldSnapshot,
note: shouldSnapshot ? "auto-save" : null,
});
if (shouldSnapshot) lastSnapshotAt = now;
isDirty = false;
setSaveStatus("saved", "✓ saved");
// Don't clear the indicator — keep "saved" visible until the next change.
} catch (e) {
setSaveStatus("error", `error: ${e.message}`);
} finally {
isSaving = false;
if (pendingSave) {
pendingSave = false;
scheduleAutoSave();
}
}
}
// ---- manual save + unload guard ----------------------------------------
$("btn-save-now").addEventListener("click", async () => {
clearTimeout(autoSaveTimer);
await performAutoSave();
});
window.addEventListener("beforeunload", (e) => {
if (isDirty) {
// Some browsers ignore custom messages but show a generic warning.
e.preventDefault();
e.returnValue = "You have unsaved changes. Leave anyway?";
return e.returnValue;
}
});
// ---- server: presets ----------------------------------------------------
async function refreshPresetList() {
try {
const list = await Api.listPresets();
const sel = $("preset-select");
const keep = sel.value;
sel.innerHTML = '<option value="">— saved preset —</option>' +
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
sel.value = keep;
} catch (e) {
console.error("refreshPresetList:", e);
}
}
// ---- live STEP preview --------------------------------------------------
$("step-preview-collapse").addEventListener("click", () => {
$("step-preview-wrap").classList.toggle("collapsed");
// After the pane resizes, canvas may need to refit — the ResizeObserver
// in viewport.js handles the backing-store, but tx/ty/scale are cached.
requestAnimationFrame(() => Viewport.fitToContent());
});
$("step-preview-toggle").addEventListener("change", (e) => {
stepPreviewEnabled = e.target.checked;
if (stepPreviewEnabled) updateStepPreview();
else {
$("step-preview-content").innerHTML =
'<div class="step-preview-empty">Live preview disabled.</div>';
$("step-preview-status").textContent = "off";
}
});
function schedulePreview() {
if (!stepPreviewEnabled) return;
clearTimeout(stepPreviewTimer);
stepPreviewTimer = setTimeout(updateStepPreview, 400);
}
async function updateStepPreview() {
const content = $("step-preview-content");
const status = $("step-preview-status");
if (!state.busbars.length) {
content.innerHTML =
'<div class="step-preview-empty">Create a busbar to see the exported geometry here.</div>';
status.textContent = "—";
return;
}
status.textContent = "loading…";
const reqId = Symbol("preview");
stepPreviewInFlight = reqId;
try {
const payload = Exporter.buildPayload(state, params);
const res = await fetch("/api/export/svg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (stepPreviewInFlight !== reqId) return;
if (!res.ok) {
let err = res.statusText;
try { err = (await res.json()).error || err; } catch {}
content.innerHTML = `<div class="step-preview-err">${escapeHtml(err)}</div>`;
status.textContent = "error";
return;
}
const svgText = await res.text();
if (stepPreviewInFlight !== reqId) return;
content.innerHTML = svgText;
const svgEl = content.querySelector("svg");
if (svgEl) {
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
status.textContent = `ok (${Math.round(svgText.length / 1024)} kB)`;
} catch (e) {
content.innerHTML = `<div class="step-preview-err">${escapeHtml(e.message)}</div>`;
status.textContent = "error";
}
}
// ---- init ---------------------------------------------------------------
async function init() {
await Promise.all([refreshProjectList(), refreshPresetList()]);
// URL routing: ?p=<id> opens that project.
const urlPid = new URLSearchParams(location.search).get("p");
if (urlPid && /^\d+$/.test(urlPid)) {
try { await openProject(+urlPid); }
catch (e) {
// Project doesn't exist anymore — strip query and show empty.
history.replaceState({}, "", location.pathname);
currentProject = null;
updateStatus();
renderBusbarList();
updateSelInfo();
}
} else {
updateStatus();
renderBusbarList();
updateSelInfo();
}
}
init();
})();