Files
wenil d8cb0dc06d 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).
2026-05-24 18:59:50 +03:00

667 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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();
})();