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:
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
})();
|
||||
Reference in New Issue
Block a user