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,45 @@
|
||||
/* api.js — thin REST client over fetch(). Throws on non-2xx so callers can
|
||||
* try/catch. Returns parsed JSON.
|
||||
*/
|
||||
|
||||
const Api = (() => {
|
||||
|
||||
async function _req(method, url, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers["content-type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try { msg = (await res.json()).error || msg; } catch {}
|
||||
const err = new Error(`${res.status} ${msg}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
return ct.includes("application/json") ? res.json() : res.text();
|
||||
}
|
||||
|
||||
return {
|
||||
// Projects
|
||||
listProjects: () => _req("GET", "/api/projects"),
|
||||
getProject: (id) => _req("GET", `/api/projects/${id}`),
|
||||
createProject: (name, data) => _req("POST", "/api/projects", { name, data }),
|
||||
updateProject: (id, payload) => _req("PUT", `/api/projects/${id}`, payload),
|
||||
deleteProject: (id) => _req("DELETE", `/api/projects/${id}`),
|
||||
|
||||
// Snapshots
|
||||
listSnapshots: (projectId) => _req("GET", `/api/projects/${projectId}/snapshots`),
|
||||
getSnapshot: (sid) => _req("GET", `/api/snapshots/${sid}`),
|
||||
restoreSnapshot: (sid) => _req("POST", `/api/snapshots/${sid}/restore`),
|
||||
|
||||
// Presets
|
||||
listPresets: () => _req("GET", "/api/presets"),
|
||||
createPreset: (name, params) => _req("POST", "/api/presets", { name, params }),
|
||||
updatePreset: (id, payload) => _req("PUT", `/api/presets/${id}`, payload),
|
||||
deletePreset: (id) => _req("DELETE", `/api/presets/${id}`),
|
||||
};
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -0,0 +1,60 @@
|
||||
/* exporter.js — collect busbars into the backend payload and download file. */
|
||||
|
||||
const Exporter = (() => {
|
||||
|
||||
function buildPayload(state, params) {
|
||||
const cellsById = new Map(state.cells.map((c) => [c.id, c]));
|
||||
const busbars = state.busbars.map((bb) => ({
|
||||
name: bb.name,
|
||||
color: bb.color,
|
||||
shape: bb.shape || "panel",
|
||||
strip_width: params.stripWidth,
|
||||
pad_radius: params.padRadius,
|
||||
hole_radius: params.holeRadius,
|
||||
hole_shape: params.holeShape || "cross",
|
||||
slit_width: params.slitWidth,
|
||||
neighbor_factor: params.neighborFactor,
|
||||
cells: bb.cells
|
||||
.map((cid) => {
|
||||
const c = cellsById.get(cid);
|
||||
return c ? { id: c.id, x: c.x, y: c.y } : null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
}));
|
||||
return {
|
||||
units: "mm",
|
||||
extrude: !!params.extrude,
|
||||
thickness: params.thickness,
|
||||
busbars,
|
||||
};
|
||||
}
|
||||
|
||||
async function exportFormat(fmt, state, params) {
|
||||
if (!state.busbars.length) {
|
||||
alert("Create at least one busbar before exporting.");
|
||||
return;
|
||||
}
|
||||
const payload = buildPayload(state, params);
|
||||
const res = await fetch(`/api/export/${fmt}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg;
|
||||
try { msg = (await res.json()).error; } catch { msg = await res.text(); }
|
||||
alert(`Export failed: ${msg}`);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `busbars.${fmt}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||
}
|
||||
|
||||
return { exportFormat, buildPayload };
|
||||
})();
|
||||
@@ -0,0 +1,144 @@
|
||||
/* geometry.js — frontend-side preview of busbar shapes.
|
||||
*
|
||||
* Mirrors busbar_export.py exactly so the canvas preview is what the STEP
|
||||
* will contain.
|
||||
*
|
||||
* panel = union of pad discs at every cell + stadium bridges between every
|
||||
* pair of cells that are neighbors (distance ≤ neighborFactor ×
|
||||
* min_pair_distance). Concave selections (L/U/T/...) hug their cells.
|
||||
*
|
||||
* wire = polyline strip of strip_width with pad discs at each cell.
|
||||
*
|
||||
* Welding windows (busbarHolesPath) are either:
|
||||
* - cross = two perpendicular slits of length 2·hole_radius, width slit_width
|
||||
* - circle = single disc of radius hole_radius
|
||||
*/
|
||||
|
||||
const Geometry = (() => {
|
||||
|
||||
function neighborEdges(pts, factor) {
|
||||
const n = pts.length;
|
||||
if (n < 2) return [];
|
||||
let minD = Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const dx = pts[j][0] - pts[i][0], dy = pts[j][1] - pts[i][1];
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 1e-9 && d < minD) minD = d;
|
||||
}
|
||||
}
|
||||
if (!isFinite(minD)) return [];
|
||||
const thr = minD * factor;
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const dx = pts[j][0] - pts[i][0], dy = pts[j][1] - pts[i][1];
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 1e-9 && d <= thr) out.push([i, j]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function busbarPath(busbar, cellsById, params) {
|
||||
return (busbar.shape === "wire")
|
||||
? wirePath(busbar, cellsById, params)
|
||||
: panelPath(busbar, cellsById, params);
|
||||
}
|
||||
|
||||
/** Welding windows path. Caller fills with destination-out to punch. */
|
||||
function busbarHolesPath(busbar, cellsById, params) {
|
||||
const path = new Path2D();
|
||||
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
|
||||
if (params.holeShape === "circle") {
|
||||
for (const c of cells) {
|
||||
path.moveTo(c.x + params.holeRadius, c.y);
|
||||
path.arc(c.x, c.y, params.holeRadius, 0, Math.PI * 2);
|
||||
}
|
||||
} else {
|
||||
// cross
|
||||
const halfW = Math.max(0.05, params.slitWidth / 2);
|
||||
const halfL = params.holeRadius;
|
||||
for (const c of cells) {
|
||||
_addRectXY(path, c.x, c.y, 2 * halfL, 2 * halfW); // horizontal arm
|
||||
_addRectXY(path, c.x, c.y, 2 * halfW, 2 * halfL); // vertical arm
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function wirePath(busbar, cellsById, params) {
|
||||
const path = new Path2D();
|
||||
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
|
||||
for (const c of cells) {
|
||||
path.moveTo(c.x + params.padRadius, c.y);
|
||||
path.arc(c.x, c.y, params.padRadius, 0, Math.PI * 2);
|
||||
}
|
||||
for (let i = 0; i < cells.length - 1; i++) {
|
||||
_addRect(path, cells[i], cells[i + 1], params.stripWidth);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Panel = disc at every cell + stadium between every neighbor pair. */
|
||||
function panelPath(busbar, cellsById, params) {
|
||||
const path = new Path2D();
|
||||
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
|
||||
if (!cells.length) return path;
|
||||
|
||||
// Discs.
|
||||
for (const c of cells) {
|
||||
path.moveTo(c.x + params.padRadius, c.y);
|
||||
path.arc(c.x, c.y, params.padRadius, 0, Math.PI * 2);
|
||||
}
|
||||
if (cells.length < 2) return path;
|
||||
|
||||
// Neighbor bridges — narrow connector (strip_width), not 2*pad_radius —
|
||||
// this gives the dog-bone shape and a real gap to neighboring busbars.
|
||||
const pts = cells.map((c) => [c.x, c.y]);
|
||||
const edges = neighborEdges(pts, params.neighborFactor || 1.15);
|
||||
for (const [i, j] of edges) {
|
||||
_addRect(path,
|
||||
{ x: pts[i][0], y: pts[i][1] },
|
||||
{ x: pts[j][0], y: pts[j][1] },
|
||||
params.stripWidth);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Rectangle from a to b of given width, as a closed Path2D sub-path.
|
||||
*
|
||||
* Vertex order matches Path2D.arc() winding so all sub-paths in the busbar
|
||||
* body wind the SAME direction. With ctx.fill(path, "nonzero") same-winding
|
||||
* subpaths union (no fill cancellation in overlap regions). If the winding
|
||||
* differs from arc(), overlap regions sum to 0 and appear as holes — the
|
||||
* 'tangled black gaps inside the busbar' bug.
|
||||
*/
|
||||
function _addRect(path, a, b, width) {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-9) return;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const px = -uy, py = ux;
|
||||
const hw = width / 2;
|
||||
// Order: above-A → below-A → below-B → above-B → close.
|
||||
path.moveTo(a.x + px * hw, a.y + py * hw);
|
||||
path.lineTo(a.x - px * hw, a.y - py * hw);
|
||||
path.lineTo(b.x - px * hw, b.y - py * hw);
|
||||
path.lineTo(b.x + px * hw, b.y + py * hw);
|
||||
path.closePath();
|
||||
}
|
||||
|
||||
/** Axis-aligned rectangle centered at (cx, cy). */
|
||||
function _addRectXY(path, cx, cy, w, h) {
|
||||
const hw = w / 2, hh = h / 2;
|
||||
path.moveTo(cx - hw, cy - hh);
|
||||
path.lineTo(cx + hw, cy - hh);
|
||||
path.lineTo(cx + hw, cy + hh);
|
||||
path.lineTo(cx - hw, cy + hh);
|
||||
path.closePath();
|
||||
}
|
||||
|
||||
return { busbarPath, busbarHolesPath, neighborEdges, panelPath, wirePath };
|
||||
})();
|
||||
@@ -0,0 +1,83 @@
|
||||
/* groups.js — busbar (parallel cell group) CRUD.
|
||||
*
|
||||
* A Busbar is { id, name, color, cells: [cellId, …] }.
|
||||
* Cell `assigned` lookup is maintained so the canvas can render colors.
|
||||
*/
|
||||
|
||||
const Groups = (() => {
|
||||
// Friendly hue palette — rotates as new busbars are added.
|
||||
const COLORS = [
|
||||
"#f08a24", "#4fa3ff", "#3ecf8e", "#e85aad",
|
||||
"#a78bfa", "#f5d142", "#52d6c6", "#ff6b6b",
|
||||
"#7cb342", "#ba68c8", "#26c6da", "#ffa726",
|
||||
];
|
||||
|
||||
let nextId = 1;
|
||||
let nextNameIdx = 1;
|
||||
|
||||
function create(state, cellIds) {
|
||||
if (!cellIds || cellIds.length === 0) return null;
|
||||
const id = nextId++;
|
||||
const bb = {
|
||||
id,
|
||||
name: `P${nextNameIdx++}`,
|
||||
color: COLORS[(id - 1) % COLORS.length],
|
||||
shape: "panel", // "panel" (default, production plate) | "wire"
|
||||
cells: [...cellIds],
|
||||
};
|
||||
state.busbars.push(bb);
|
||||
_reassign(state);
|
||||
return bb;
|
||||
}
|
||||
|
||||
function remove(state, busbarId) {
|
||||
state.busbars = state.busbars.filter((b) => b.id !== busbarId);
|
||||
_reassign(state);
|
||||
}
|
||||
|
||||
function rename(state, busbarId, name) {
|
||||
const bb = state.busbars.find((b) => b.id === busbarId);
|
||||
if (bb) bb.name = name;
|
||||
}
|
||||
|
||||
function recolor(state, busbarId, color) {
|
||||
const bb = state.busbars.find((b) => b.id === busbarId);
|
||||
if (bb) bb.color = color;
|
||||
}
|
||||
|
||||
function addCells(state, busbarId, cellIds) {
|
||||
const bb = state.busbars.find((b) => b.id === busbarId);
|
||||
if (!bb) return;
|
||||
const set = new Set(bb.cells);
|
||||
for (const id of cellIds) {
|
||||
if (!set.has(id)) { bb.cells.push(id); set.add(id); }
|
||||
}
|
||||
_reassign(state);
|
||||
}
|
||||
|
||||
function removeCells(state, busbarId, cellIds) {
|
||||
const bb = state.busbars.find((b) => b.id === busbarId);
|
||||
if (!bb) return;
|
||||
const rm = new Set(cellIds);
|
||||
bb.cells = bb.cells.filter((id) => !rm.has(id));
|
||||
_reassign(state);
|
||||
}
|
||||
|
||||
function _reassign(state) {
|
||||
state.cellToBusbar = new Map();
|
||||
for (const bb of state.busbars) {
|
||||
for (const cid of bb.cells) state.cellToBusbar.set(cid, bb.id);
|
||||
}
|
||||
}
|
||||
|
||||
function findByCell(state, cellId) {
|
||||
return state.cellToBusbar.get(cellId);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
nextId = 1;
|
||||
nextNameIdx = 1;
|
||||
}
|
||||
|
||||
return { create, remove, rename, recolor, addCells, removeCells, findByCell, reset };
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
/* importer.js — parse OpenSCAD ECHO / CSV / JSON / parametric generator.
|
||||
*
|
||||
* All parsers return an array of {id, x, y} in millimetres.
|
||||
* Generator formulas are 1:1 mirrors of `get_hex_center_points_*` from
|
||||
* Addy's hex_cell.scad (see CLAUDE.md for the cheat sheet).
|
||||
*/
|
||||
|
||||
const Importer = (() => {
|
||||
const COS30 = Math.cos(Math.PI / 6);
|
||||
|
||||
/** Parse OpenSCAD ECHO lines or loose `Cell N: x = …, y = …`. */
|
||||
function parsePaste(text) {
|
||||
const out = [];
|
||||
const re = /Cell\s*(\d+)\s*:\s*x\s*=\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*,\s*y\s*=\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
out.push({ id: parseInt(m[1], 10), x: parseFloat(m[2]), y: parseFloat(m[3]) });
|
||||
}
|
||||
if (out.length) return out;
|
||||
|
||||
// Fallback: lines of form `index x y` or `index, x, y` (any separator).
|
||||
const lines = text.split(/\r?\n/);
|
||||
let auto = 1;
|
||||
for (const line of lines) {
|
||||
const nums = line.match(/-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?/g);
|
||||
if (!nums) continue;
|
||||
if (nums.length >= 3) {
|
||||
out.push({ id: parseInt(nums[0], 10), x: parseFloat(nums[1]), y: parseFloat(nums[2]) });
|
||||
} else if (nums.length === 2) {
|
||||
out.push({ id: auto++, x: parseFloat(nums[0]), y: parseFloat(nums[1]) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** CSV with optional header. Columns: index,x,y OR x,y. */
|
||||
function parseCSV(text) {
|
||||
const out = [];
|
||||
const lines = text.trim().split(/\r?\n/).filter(Boolean);
|
||||
let auto = 1;
|
||||
for (const raw of lines) {
|
||||
const cols = raw.split(/[,;\t]/).map((s) => s.trim());
|
||||
// Skip header rows that don't parse as numbers.
|
||||
if (cols.length >= 2 && isNaN(parseFloat(cols[0])) && isNaN(parseFloat(cols[1]))) continue;
|
||||
if (cols.length >= 3) {
|
||||
const id = parseInt(cols[0], 10);
|
||||
const x = parseFloat(cols[1]);
|
||||
const y = parseFloat(cols[2]);
|
||||
if (!isNaN(x) && !isNaN(y)) out.push({ id: isNaN(id) ? auto++ : id, x, y });
|
||||
} else if (cols.length === 2) {
|
||||
const x = parseFloat(cols[0]);
|
||||
const y = parseFloat(cols[1]);
|
||||
if (!isNaN(x) && !isNaN(y)) out.push({ id: auto++, x, y });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** JSON: array of objects {id,x,y} OR array of [x,y]. */
|
||||
function parseJSON(text) {
|
||||
const j = JSON.parse(text);
|
||||
if (!Array.isArray(j)) throw new Error("JSON must be a top-level array");
|
||||
return j.map((item, i) => {
|
||||
if (Array.isArray(item)) {
|
||||
return { id: i + 1, x: +item[0], y: +item[1] };
|
||||
}
|
||||
return {
|
||||
id: item.id != null ? +item.id : i + 1,
|
||||
x: +item.x,
|
||||
y: +item.y,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Generator — mirrors get_hex_center_points_{rect,para,tria} from hex_cell.scad. */
|
||||
function generate({ cellDia, wall, rows, cols, style }) {
|
||||
const hex_w = cellDia + 2 * wall;
|
||||
const hex_pt = (hex_w / 2) / COS30;
|
||||
const rowY = (r) => r * 1.5 * hex_pt;
|
||||
const out = [];
|
||||
let id = 1;
|
||||
|
||||
if (style === "rect") {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = (r % 2 === 0) ? hex_w * c : 0.5 * hex_w + hex_w * c;
|
||||
out.push({ id: id++, x, y: rowY(r) });
|
||||
}
|
||||
}
|
||||
} else if (style === "para") {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = r * 0.5 * hex_w + hex_w * c;
|
||||
out.push({ id: id++, x, y: rowY(r) });
|
||||
}
|
||||
}
|
||||
} else if (style === "tria") {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c <= r; c++) {
|
||||
const x = r * 0.5 * hex_w - hex_w * c;
|
||||
out.push({ id: id++, x, y: rowY(r) });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unknown style: ${style}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return { parsePaste, parseCSV, parseJSON, generate };
|
||||
})();
|
||||
@@ -0,0 +1,320 @@
|
||||
/* viewport.js — 2D canvas viewer with real mm scale, pan, zoom, cell picking.
|
||||
*
|
||||
* Coordinates in app state are millimetres; we keep a pan (tx, ty in px) and a
|
||||
* scale (px-per-mm). Y axis is flipped on render so +Y points up like CAD.
|
||||
*/
|
||||
|
||||
const Viewport = (() => {
|
||||
let canvas, ctx, state, params;
|
||||
let scale = 4; // px per mm
|
||||
let tx = 0, ty = 0; // pan offset, in pixels (added after scaling)
|
||||
let isPanning = false;
|
||||
let panStart = null;
|
||||
let cellHoverId = null;
|
||||
|
||||
// Callbacks set by app.js.
|
||||
let onCellClick = () => {};
|
||||
let onCursorMove = () => {};
|
||||
let onZoomChange = () => {};
|
||||
|
||||
function init(canvasEl, stateRef, paramsRef, handlers) {
|
||||
canvas = canvasEl;
|
||||
ctx = canvas.getContext("2d");
|
||||
state = stateRef;
|
||||
params = paramsRef;
|
||||
onCellClick = handlers.onCellClick || onCellClick;
|
||||
onCursorMove = handlers.onCursorMove || onCursorMove;
|
||||
onZoomChange = handlers.onZoomChange || onZoomChange;
|
||||
|
||||
window.addEventListener("resize", _resize);
|
||||
// Catch any layout change (flex re-flow, sidebar toggle, etc.) — the
|
||||
// canvas backing-store must match its CSS size or content draws blurry /
|
||||
// mis-positioned ('cells off-screen' bug).
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
new ResizeObserver(_resize).observe(canvas);
|
||||
}
|
||||
canvas.addEventListener("mousedown", _onMouseDown);
|
||||
canvas.addEventListener("mousemove", _onMouseMove);
|
||||
canvas.addEventListener("mouseup", _onMouseUp);
|
||||
canvas.addEventListener("mouseleave", _onMouseUp);
|
||||
canvas.addEventListener("wheel", _onWheel, { passive: false });
|
||||
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
_resize();
|
||||
}
|
||||
|
||||
function _resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.max(1, Math.round(r.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(r.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // user space = CSS pixels
|
||||
render();
|
||||
}
|
||||
|
||||
function fitToContent() {
|
||||
// Defer one frame so any pending layout (flex re-flow when busbar list
|
||||
// grows, preview pane appears, etc.) is computed before we measure
|
||||
// canvas dimensions. Without this, clientWidth/Height can be 0 → cells
|
||||
// end up off-screen.
|
||||
requestAnimationFrame(_fitToContentNow);
|
||||
}
|
||||
|
||||
function _fitToContentNow() {
|
||||
_resize();
|
||||
if (!state.cells || state.cells.length === 0) {
|
||||
tx = canvas.clientWidth / 2;
|
||||
ty = canvas.clientHeight / 2;
|
||||
scale = 4;
|
||||
onZoomChange(scale);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const cellRadius = (params.cellDia || 21.2) / 2;
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const c of state.cells) {
|
||||
if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x;
|
||||
if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y;
|
||||
}
|
||||
minX -= cellRadius; maxX += cellRadius;
|
||||
minY -= cellRadius; maxY += cellRadius;
|
||||
const w = maxX - minX, h = maxY - minY;
|
||||
const margin = 40;
|
||||
const sx = (canvas.clientWidth - 2 * margin) / w;
|
||||
const sy = (canvas.clientHeight - 2 * margin) / h;
|
||||
scale = Math.max(0.5, Math.min(40, Math.min(sx, sy)));
|
||||
// World point at canvas center should be (minX+w/2, minY+h/2).
|
||||
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
||||
tx = canvas.clientWidth / 2 - cx * scale;
|
||||
ty = canvas.clientHeight / 2 + cy * scale; // +cy*scale because Y is flipped
|
||||
onZoomChange(scale);
|
||||
render();
|
||||
}
|
||||
|
||||
// World (mm) → screen (px)
|
||||
function w2s(x, y) {
|
||||
return { x: x * scale + tx, y: -y * scale + ty };
|
||||
}
|
||||
// Screen → world
|
||||
function s2w(px, py) {
|
||||
return { x: (px - tx) / scale, y: -(py - ty) / scale };
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!ctx) return;
|
||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
_drawGrid(W, H);
|
||||
_drawCells();
|
||||
_drawBusbars();
|
||||
_drawCellLabels(); // labels on top so the cross-slit punch doesn't eat them
|
||||
_drawSelection();
|
||||
}
|
||||
|
||||
function _drawGrid(W, H) {
|
||||
// Minor grid every 5 mm, major every 50 mm.
|
||||
const minor = 5, major = 50;
|
||||
const tl = s2w(0, 0), br = s2w(W, H);
|
||||
const xMin = Math.floor(Math.min(tl.x, br.x) / minor) * minor;
|
||||
const xMax = Math.ceil (Math.max(tl.x, br.x) / minor) * minor;
|
||||
const yMin = Math.floor(Math.min(tl.y, br.y) / minor) * minor;
|
||||
const yMax = Math.ceil (Math.max(tl.y, br.y) / minor) * minor;
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = xMin; x <= xMax; x += minor) {
|
||||
const p = w2s(x, 0);
|
||||
ctx.strokeStyle = (Math.abs(x % major) < 1e-6) ? "#2a3140" : "#1f2530";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, 0); ctx.lineTo(p.x, H); ctx.stroke();
|
||||
}
|
||||
for (let y = yMin; y <= yMax; y += minor) {
|
||||
const p = w2s(0, y);
|
||||
ctx.strokeStyle = (Math.abs(y % major) < 1e-6) ? "#2a3140" : "#1f2530";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, p.y); ctx.lineTo(W, p.y); ctx.stroke();
|
||||
}
|
||||
// Origin marker.
|
||||
const o = w2s(0, 0);
|
||||
ctx.strokeStyle = "#ff6b6b";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(o.x - 8, o.y); ctx.lineTo(o.x + 8, o.y);
|
||||
ctx.moveTo(o.x, o.y - 8); ctx.lineTo(o.x, o.y + 8);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function _drawCells() {
|
||||
const r = (params.cellDia || 21.2) / 2;
|
||||
const o = (params.openingDia || 15.2) / 2;
|
||||
const hexW = (params.cellDia || 21.2) + 2 * 0.8;
|
||||
const hexPt = hexW / 2 / Math.cos(Math.PI / 6);
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// For cells assigned to a busbar we draw NOTHING here — the busbar layer
|
||||
// owns those pixels entirely (fill + cross slit + label). Drawing hex /
|
||||
// circle outlines under the busbar muddies the preview.
|
||||
for (const c of state.cells) {
|
||||
if (Groups.findByCell(state, c.id) != null) continue;
|
||||
|
||||
const p = w2s(c.x, c.y);
|
||||
const rPx = r * scale;
|
||||
const oPx = o * scale;
|
||||
|
||||
// Hex outline.
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = Math.PI / 2 + i * Math.PI / 3;
|
||||
const hx = p.x + hexPt * scale * Math.cos(a);
|
||||
const hy = p.y - hexPt * scale * Math.sin(a);
|
||||
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.strokeStyle = "#262d3a";
|
||||
ctx.stroke();
|
||||
|
||||
// Cell circle + opening hint (so the user sees the welding window).
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, rPx, 0, Math.PI * 2);
|
||||
ctx.fillStyle = _withAlpha("#4a5365", 0.18);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "#4a5365";
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = _withAlpha("#4a5365", 0.35);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, oPx, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function _drawCellLabels() {
|
||||
if (scale <= 2) return;
|
||||
ctx.font = `${Math.max(9, Math.min(14, scale * 1.5))}px -apple-system, system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (const c of state.cells) {
|
||||
const p = w2s(c.x, c.y);
|
||||
const inBusbar = Groups.findByCell(state, c.id) != null;
|
||||
ctx.fillStyle = inBusbar ? "#1a1a1a" : "#cdd5e0";
|
||||
ctx.fillText(String(c.id), p.x, p.y);
|
||||
}
|
||||
}
|
||||
|
||||
function _drawBusbars() {
|
||||
if (!state.busbars.length) return;
|
||||
const cellsById = new Map(state.cells.map((c) => [c.id, c]));
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.save();
|
||||
// Set transform so we can draw busbar Path2D in world (mm) coords.
|
||||
// screen.x = scale*world.x + tx ; screen.y = -scale*world.y + ty
|
||||
ctx.setTransform(dpr * scale, 0, 0, -dpr * scale, dpr * tx, dpr * ty);
|
||||
|
||||
for (const bb of state.busbars) {
|
||||
const params2 = {
|
||||
padRadius: params.padRadius,
|
||||
holeRadius: params.holeRadius,
|
||||
stripWidth: params.stripWidth,
|
||||
};
|
||||
const body = Geometry.busbarPath(bb, cellsById, params2);
|
||||
const holes = Geometry.busbarHolesPath(bb, cellsById, params2);
|
||||
|
||||
// Body: union semantics — fill nonzero so overlapping rectangles/discs
|
||||
// don't carve evenodd holes. Higher opacity than v1 so the panel reads
|
||||
// as a single object, not a translucent veil.
|
||||
ctx.fillStyle = _withAlpha(bb.color, 0.75);
|
||||
ctx.fill(body, "nonzero");
|
||||
|
||||
// Holes: punch via destination-out so the dark canvas shows through.
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fill(holes, "nonzero");
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function _drawSelection() {
|
||||
if (!state.selection.size) return;
|
||||
const r = ((params.cellDia || 21.2) / 2 + 1.5);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = "#f08a24";
|
||||
for (const id of state.selection) {
|
||||
const c = state.cells.find((cc) => cc.id === id);
|
||||
if (!c) continue;
|
||||
const p = w2s(c.x, c.y);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, r * scale, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function _withAlpha(hex, a) {
|
||||
const s = hex.replace("#", "");
|
||||
const r = parseInt(s.slice(0, 2), 16);
|
||||
const g = parseInt(s.slice(2, 4), 16);
|
||||
const b = parseInt(s.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
function _pickCell(px, py) {
|
||||
const r = (params.cellDia || 21.2) / 2;
|
||||
let best = null, bestDist = Infinity;
|
||||
for (const c of state.cells) {
|
||||
const p = w2s(c.x, c.y);
|
||||
const d = Math.hypot(p.x - px, p.y - py);
|
||||
if (d < r * scale && d < bestDist) { best = c; bestDist = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function _onMouseDown(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||||
if (e.button === 2 || e.button === 1) {
|
||||
isPanning = true;
|
||||
panStart = { px, py, tx, ty };
|
||||
canvas.style.cursor = "grabbing";
|
||||
return;
|
||||
}
|
||||
if (e.button === 0) {
|
||||
const c = _pickCell(px, py);
|
||||
if (c) onCellClick(c.id, { shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey });
|
||||
}
|
||||
}
|
||||
|
||||
function _onMouseMove(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||||
if (isPanning && panStart) {
|
||||
tx = panStart.tx + (px - panStart.px);
|
||||
ty = panStart.ty + (py - panStart.py);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const w = s2w(px, py);
|
||||
onCursorMove(w.x, w.y);
|
||||
}
|
||||
|
||||
function _onMouseUp() {
|
||||
isPanning = false;
|
||||
panStart = null;
|
||||
canvas.style.cursor = "crosshair";
|
||||
}
|
||||
|
||||
function _onWheel(e) {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||||
const pre = s2w(px, py);
|
||||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
scale = Math.max(0.5, Math.min(80, scale * factor));
|
||||
// Keep cursor mm-point stationary.
|
||||
tx = px - pre.x * scale;
|
||||
ty = py + pre.y * scale;
|
||||
onZoomChange(scale);
|
||||
render();
|
||||
}
|
||||
|
||||
return { init, render, fitToContent };
|
||||
})();
|
||||
Reference in New Issue
Block a user