/* 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=; 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 = '
  • No snapshots yet.
  • '; } else { ul.innerHTML = snaps.map((s) => `
  • ${escapeHtml(s.created_at)} ${escapeHtml(s.note || "(auto)")}
  • `).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 = '' + list.map((p) => ``).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 = '' + list.map((p) => ``).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 = '
    Live preview disabled.
    '; $("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 = '
    Create a busbar to see the exported geometry here.
    '; 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 = `
    ${escapeHtml(err)}
    `; 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 = `
    ${escapeHtml(e.message)}
    `; status.textContent = "error"; } } // ---- init --------------------------------------------------------------- async function init() { await Promise.all([refreshProjectList(), refreshPresetList()]); // URL routing: ?p= 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(); })();