const GRID = 20; const SNAPSHOTS_KEY = "schemeta:snapshots:v2"; const SCHEMA_URL = "/schemeta.schema.json"; const NET_CLASSES = ["power", "ground", "signal", "analog", "differential", "clock", "bus"]; const PIN_SIDES = ["left", "right", "top", "bottom"]; const PIN_TYPES = ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"]; const state = { model: null, compile: null, selectedRef: null, selectedRefs: [], selectedNet: null, selectedPin: null, scale: 1, panX: 40, panY: 40, isPanning: false, dragComponent: null, draggingComponentRef: null, dragPointerId: null, dragPreviewNode: null, dragMoved: false, showLabels: true, isolateNet: false, isolateComponent: false, renderMode: "schematic_stub", userAdjustedView: false, spacePan: false, schemaText: "", boxSelecting: false, boxStartX: 0, boxStartY: 0, boxMoved: false, suppressCanvasClick: false, compileDebounceId: null }; const el = { instanceList: document.getElementById("instanceList"), netList: document.getElementById("netList"), instanceFilter: document.getElementById("instanceFilter"), netFilter: document.getElementById("netFilter"), canvasViewport: document.getElementById("canvasViewport"), canvasInner: document.getElementById("canvasInner"), selectionBox: document.getElementById("selectionBox"), compileStatus: document.getElementById("compileStatus"), selectedSummary: document.getElementById("selectedSummary"), componentEditor: document.getElementById("componentEditor"), symbolEditor: document.getElementById("symbolEditor"), pinEditor: document.getElementById("pinEditor"), netEditor: document.getElementById("netEditor"), instRefInput: document.getElementById("instRefInput"), instValueInput: document.getElementById("instValueInput"), instNotesInput: document.getElementById("instNotesInput"), xInput: document.getElementById("xInput"), yInput: document.getElementById("yInput"), rotationInput: document.getElementById("rotationInput"), lockedInput: document.getElementById("lockedInput"), rotateSelectedBtn: document.getElementById("rotateSelectedBtn"), updatePlacementBtn: document.getElementById("updatePlacementBtn"), symbolMeta: document.getElementById("symbolMeta"), symbolCategoryInput: document.getElementById("symbolCategoryInput"), symbolWidthInput: document.getElementById("symbolWidthInput"), symbolHeightInput: document.getElementById("symbolHeightInput"), addSymbolPinBtn: document.getElementById("addSymbolPinBtn"), applySymbolBtn: document.getElementById("applySymbolBtn"), symbolPinsList: document.getElementById("symbolPinsList"), pinMeta: document.getElementById("pinMeta"), pinNameInput: document.getElementById("pinNameInput"), pinNumberInput: document.getElementById("pinNumberInput"), pinSideInput: document.getElementById("pinSideInput"), pinTypeInput: document.getElementById("pinTypeInput"), pinOffsetInput: document.getElementById("pinOffsetInput"), applyPinPropsBtn: document.getElementById("applyPinPropsBtn"), showPinNetLabelInput: document.getElementById("showPinNetLabelInput"), pinNetSelect: document.getElementById("pinNetSelect"), connectPinBtn: document.getElementById("connectPinBtn"), newNetNameInput: document.getElementById("newNetNameInput"), newNetClassInput: document.getElementById("newNetClassInput"), createConnectNetBtn: document.getElementById("createConnectNetBtn"), pinConnections: document.getElementById("pinConnections"), netNameInput: document.getElementById("netNameInput"), netClassInput: document.getElementById("netClassInput"), updateNetBtn: document.getElementById("updateNetBtn"), netNodeRefInput: document.getElementById("netNodeRefInput"), netNodePinInput: document.getElementById("netNodePinInput"), addNetNodeBtn: document.getElementById("addNetNodeBtn"), netNodesList: document.getElementById("netNodesList"), issues: document.getElementById("issues"), topology: document.getElementById("topology"), jsonEditor: document.getElementById("jsonEditor"), jsonFeedback: document.getElementById("jsonFeedback"), loadSampleBtn: document.getElementById("loadSampleBtn"), newProjectBtn: document.getElementById("newProjectBtn"), importBtn: document.getElementById("importBtn"), exportBtn: document.getElementById("exportBtn"), fileInput: document.getElementById("fileInput"), zoomInBtn: document.getElementById("zoomInBtn"), zoomOutBtn: document.getElementById("zoomOutBtn"), zoomResetBtn: document.getElementById("zoomResetBtn"), fitViewBtn: document.getElementById("fitViewBtn"), showLabelsInput: document.getElementById("showLabelsInput"), applyJsonBtn: document.getElementById("applyJsonBtn"), showSchemaBtn: document.getElementById("showSchemaBtn"), validateJsonBtn: document.getElementById("validateJsonBtn"), formatJsonBtn: document.getElementById("formatJsonBtn"), sortJsonBtn: document.getElementById("sortJsonBtn"), copyReproBtn: document.getElementById("copyReproBtn"), autoLayoutBtn: document.getElementById("autoLayoutBtn"), autoTidyBtn: document.getElementById("autoTidyBtn"), renderModeSelect: document.getElementById("renderModeSelect"), isolateNetBtn: document.getElementById("isolateNetBtn"), isolateComponentBtn: document.getElementById("isolateComponentBtn"), pinTooltip: document.getElementById("pinTooltip"), schemaModal: document.getElementById("schemaModal"), schemaViewer: document.getElementById("schemaViewer"), closeSchemaBtn: document.getElementById("closeSchemaBtn"), copySchemaBtn: document.getElementById("copySchemaBtn"), downloadSchemaBtn: document.getElementById("downloadSchemaBtn") }; function toGrid(v) { return Math.round(v / GRID) * GRID; } function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function hasSelectionModifier(evt) { return Boolean(evt?.ctrlKey || evt?.metaKey || evt?.shiftKey); } function setSelectedRefs(refs) { const uniq = [...new Set((refs ?? []).filter(Boolean))]; state.selectedRefs = uniq; state.selectedRef = uniq.length === 1 ? uniq[0] : null; } function selectSingleRef(ref) { setSelectedRefs(ref ? [ref] : []); } function toggleSelectedRef(ref) { const set = new Set(state.selectedRefs); if (set.has(ref)) { set.delete(ref); } else { set.add(ref); } setSelectedRefs([...set]); } function selectedRefSet() { return new Set(state.selectedRefs); } function instanceByRef(ref) { return state.model?.instances.find((i) => i.ref === ref) ?? null; } function symbolForRef(ref) { const inst = instanceByRef(ref); if (!inst) { return null; } return state.model?.symbols?.[inst.symbol] ?? null; } function pinExists(ref, pinName) { const sym = symbolForRef(ref); return Boolean(sym?.pins?.some((p) => p.name === pinName)); } function normalizeRef(raw) { return String(raw ?? "") .trim() .replace(/\s+/g, "_"); } function normalizeNetName(raw) { return String(raw ?? "") .trim() .replace(/\s+/g, "_"); } function escHtml(text) { return String(text ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } async function apiPost(path, payload) { const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); const data = await res.json(); if (!res.ok) { throw new Error(data?.error?.message || "Request failed"); } return data; } function setStatus(text, ok = true) { el.compileStatus.textContent = text; el.compileStatus.className = ok ? "status-ok" : ""; } function defaultProject() { return { meta: { title: "Untitled Schemeta Project" }, symbols: {}, instances: [], nets: [], constraints: {}, annotations: [] }; } function compileOptions() { return { render_mode: state.renderMode, show_labels: state.showLabels, generic_symbols: true }; } function applyCompileLayoutToModel(model, compileResult) { const next = clone(model); const placed = compileResult?.layout?.placed; if (!Array.isArray(placed)) { return next; } const byRef = new Map(placed.map((p) => [p.ref, p])); for (const inst of next.instances) { const p = byRef.get(inst.ref); if (!p) { continue; } inst.placement.x = p.x; inst.placement.y = p.y; inst.placement.rotation = p.rotation ?? inst.placement.rotation ?? 0; inst.placement.locked = p.locked ?? inst.placement.locked ?? false; } return next; } function reconcileSelectionWithModel() { if (!state.model) { setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; return; } const refs = new Set(state.model.instances.map((i) => i.ref)); setSelectedRefs(state.selectedRefs.filter((r) => refs.has(r))); if (state.selectedPin) { if (!refs.has(state.selectedPin.ref) || !pinExists(state.selectedPin.ref, state.selectedPin.pin)) { state.selectedPin = null; } } if (state.selectedNet) { const exists = state.model.nets.some((n) => n.name === state.selectedNet); if (!exists) { state.selectedNet = null; } } } function refreshJsonEditor() { if (!state.model) { return; } el.jsonEditor.value = JSON.stringify(state.model, null, 2); } function saveSnapshot() { if (!state.model) { return; } const snap = { id: `${Date.now()}`, ts: new Date().toISOString(), model: state.model }; const existing = JSON.parse(localStorage.getItem(SNAPSHOTS_KEY) ?? "[]"); const next = [snap, ...existing].slice(0, 20); localStorage.setItem(SNAPSHOTS_KEY, JSON.stringify(next)); } function updateTransform() { el.canvasInner.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`; el.zoomResetBtn.textContent = `${Math.round(state.scale * 100)}%`; } function fitView(layout) { const w = layout?.width ?? 0; const h = layout?.height ?? 0; if (!w || !h) { return; } let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; const byRef = new Map((layout?.placed ?? []).map((p) => [p.ref, p])); if (state.model?.instances?.length) { for (const inst of state.model.instances) { const p = byRef.get(inst.ref); const sym = state.model.symbols?.[inst.symbol]; if (!p || !sym) { continue; } minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x + sym.body.width); maxY = Math.max(maxY, p.y + sym.body.height); } } if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) { minX = 0; minY = 0; maxX = w; maxY = h; } const pad = 80; const bbox = { x: Math.max(0, minX - pad), y: Math.max(0, minY - pad), w: Math.min(w, maxX - minX + pad * 2), h: Math.min(h, maxY - minY + pad * 2) }; const viewport = el.canvasViewport.getBoundingClientRect(); const sx = (viewport.width * 0.98) / Math.max(1, bbox.w); const sy = (viewport.height * 0.98) / Math.max(1, bbox.h); state.scale = Math.max(0.2, Math.min(4, Math.min(sx, sy))); state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale; state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale; state.userAdjustedView = false; updateTransform(); } function zoomToBBox(bbox) { if (!bbox) { return; } const viewport = el.canvasViewport.getBoundingClientRect(); const scaleX = (viewport.width * 0.75) / Math.max(1, bbox.w); const scaleY = (viewport.height * 0.75) / Math.max(1, bbox.h); state.scale = Math.max(0.3, Math.min(4, Math.min(scaleX, scaleY))); state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale; state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale; state.userAdjustedView = true; updateTransform(); } function canvasToSvgPoint(clientX, clientY) { const rect = el.canvasViewport.getBoundingClientRect(); return { x: (clientX - rect.left - state.panX) / state.scale, y: (clientY - rect.top - state.panY) / state.scale }; } function viewportPoint(clientX, clientY) { const rect = el.canvasViewport.getBoundingClientRect(); return { x: clientX - rect.left, y: clientY - rect.top }; } function beginBoxSelection(clientX, clientY) { const p = viewportPoint(clientX, clientY); state.boxSelecting = true; state.boxMoved = false; state.boxStartX = p.x; state.boxStartY = p.y; el.selectionBox.classList.remove("hidden"); el.selectionBox.style.left = `${p.x}px`; el.selectionBox.style.top = `${p.y}px`; el.selectionBox.style.width = "0px"; el.selectionBox.style.height = "0px"; } function updateBoxSelection(clientX, clientY) { if (!state.boxSelecting) { return; } const p = viewportPoint(clientX, clientY); const x = Math.min(state.boxStartX, p.x); const y = Math.min(state.boxStartY, p.y); const w = Math.abs(p.x - state.boxStartX); const h = Math.abs(p.y - state.boxStartY); if (w > 4 || h > 4) { state.boxMoved = true; } el.selectionBox.style.left = `${x}px`; el.selectionBox.style.top = `${y}px`; el.selectionBox.style.width = `${w}px`; el.selectionBox.style.height = `${h}px`; } function finishBoxSelection() { if (!state.boxSelecting) { return; } const box = el.selectionBox.getBoundingClientRect(); el.selectionBox.classList.add("hidden"); const moved = state.boxMoved; state.boxSelecting = false; state.boxMoved = false; if (!moved) { return; } state.suppressCanvasClick = true; const svg = el.canvasInner.querySelector("svg"); if (!svg) { return; } const hits = []; svg.querySelectorAll("[data-ref]").forEach((node) => { const r = node.getBoundingClientRect(); const intersects = r.left < box.right && r.right > box.left && r.top < box.bottom && r.bottom > box.top; if (intersects) { const ref = node.getAttribute("data-ref"); if (ref) { hits.push(ref); } } }); if (hits.length) { setSelectedRefs(hits); state.selectedNet = null; state.selectedPin = null; renderAll(); } else { setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; state.isolateNet = false; state.isolateComponent = false; renderAll(); } } function incidentNetsForRef(ref) { if (!state.model) { return new Set(); } return new Set( state.model.nets .filter((net) => net.nodes.some((n) => n.ref === ref)) .map((net) => net.name) ); } function refsConnectedToNet(netName) { const net = state.model?.nets.find((n) => n.name === netName); return new Set((net?.nodes ?? []).map((n) => n.ref)); } function activeNetSet() { if (state.selectedNet) { return new Set([state.selectedNet]); } if (state.selectedRefs.length) { const nets = new Set(); for (const ref of state.selectedRefs) { for (const net of incidentNetsForRef(ref)) { nets.add(net); } } return nets; } return null; } function setLabelLayerVisibility() { const svg = el.canvasInner.querySelector("svg"); if (!svg) { return; } const layer = svg.querySelector('[data-layer="net-labels"]'); if (layer) { layer.style.display = state.showLabels ? "" : "none"; } } function renderInstances() { if (!state.model) { el.instanceList.innerHTML = ""; return; } const q = el.instanceFilter.value.trim().toLowerCase(); const items = state.model.instances.filter((i) => i.ref.toLowerCase().includes(q)); el.instanceList.innerHTML = items .map((inst) => { const cls = state.selectedRefs.includes(inst.ref) ? "active" : ""; return `
  • ${inst.ref} · ${inst.symbol}
  • `; }) .join(""); } function renderNets() { if (!state.model) { el.netList.innerHTML = ""; return; } const q = el.netFilter.value.trim().toLowerCase(); const items = state.model.nets.filter((n) => n.name.toLowerCase().includes(q)); el.netList.innerHTML = items .map((net) => { const cls = net.name === state.selectedNet ? "active" : ""; return `
  • ${net.name} (${net.class})
  • `; }) .join(""); } function netByName(name) { return state.model?.nets.find((n) => n.name === name) ?? null; } function pinUi(inst, pinName) { const ui = inst?.properties?.pin_ui; if (!ui || typeof ui !== "object") { return {}; } const pinObj = ui[pinName]; if (!pinObj || typeof pinObj !== "object") { return {}; } return pinObj; } function inferClassForPin(ref, pinName) { const sym = symbolForRef(ref); const pin = sym?.pins?.find((p) => p.name === pinName); const pinType = String(pin?.type ?? "").toLowerCase(); if (pinType === "ground") { return "ground"; } if (pinType === "power_in" || pinType === "power_out") { return "power"; } if (pinType === "analog") { return "analog"; } return "signal"; } function nextAutoNetName() { const names = new Set((state.model?.nets ?? []).map((n) => n.name)); let n = 1; while (names.has(`NET_${n}`)) { n += 1; } return `NET_${n}`; } function renamePinAcrossSymbolInstances(symbolId, oldPinName, newPinName) { if (!state.model || !oldPinName || !newPinName || oldPinName === newPinName) { return; } const refs = new Set((state.model.instances ?? []).filter((i) => i.symbol === symbolId).map((i) => i.ref)); for (const net of state.model.nets ?? []) { for (const node of net.nodes ?? []) { if (refs.has(node.ref) && node.pin === oldPinName) { node.pin = newPinName; } } } if (state.selectedPin && refs.has(state.selectedPin.ref) && state.selectedPin.pin === oldPinName) { state.selectedPin.pin = newPinName; } } function symbolPinRowHtml(pin) { const sideOptions = PIN_SIDES.map((s) => ``).join(""); const typeOptions = PIN_TYPES.map((t) => ``).join(""); return `
    `; } function renderSymbolEditorForRef(ref) { const inst = instanceByRef(ref); const sym = symbolForRef(ref); if (!inst || !sym) { el.symbolEditor.classList.add("hidden"); return; } el.symbolMeta.textContent = `Symbol ${inst.symbol} (${(sym.pins ?? []).length} pins)`; el.symbolCategoryInput.value = String(sym.category ?? ""); el.symbolWidthInput.value = String(Number(sym.body?.width ?? 120)); el.symbolHeightInput.value = String(Number(sym.body?.height ?? 80)); el.symbolPinsList.innerHTML = (sym.pins ?? []).map((pin) => symbolPinRowHtml(pin)).join(""); el.symbolEditor.classList.remove("hidden"); } function renderPinEditor() { if (!state.selectedPin || !state.model) { el.pinEditor.classList.add("hidden"); return; } const { ref, pin } = state.selectedPin; const inst = instanceByRef(ref); const sym = symbolForRef(ref); const pinDef = sym?.pins?.find((p) => p.name === pin); if (!inst) { el.pinEditor.classList.add("hidden"); return; } const nets = [...new Set((state.model.nets ?? []).filter((n) => n.nodes.some((x) => x.ref === ref && x.pin === pin)).map((n) => n.name))]; const ui = pinUi(inst, pin); el.pinMeta.textContent = `${ref}.${pin} | Nets: ${nets.length ? nets.join(", ") : "(unconnected)"}`; el.pinNameInput.value = String(pinDef?.name ?? pin); el.pinNumberInput.value = String(pinDef?.number ?? ""); el.pinSideInput.value = PIN_SIDES.includes(String(pinDef?.side ?? "")) ? String(pinDef.side) : "left"; el.pinTypeInput.value = PIN_TYPES.includes(String(pinDef?.type ?? "")) ? String(pinDef.type) : "passive"; el.pinOffsetInput.value = String(Number(pinDef?.offset ?? 0)); el.showPinNetLabelInput.checked = Boolean(ui.show_net_label ?? inst.properties?.show_net_labels); el.pinNetSelect.innerHTML = (state.model.nets ?? []) .map((n) => ``) .join(""); el.newNetNameInput.placeholder = nextAutoNetName(); el.newNetClassInput.value = inferClassForPin(ref, pin); el.pinConnections.innerHTML = nets.length ? nets .map( (name) => `
    ${name}
    ` ) .join("") : `
    No net connections yet.
    `; el.pinEditor.classList.remove("hidden"); } function renderNetEditor() { if (!state.selectedNet || !state.model) { el.netEditor.classList.add("hidden"); return; } const net = netByName(state.selectedNet); if (!net) { el.netEditor.classList.add("hidden"); return; } el.netNameInput.value = net.name; el.netClassInput.value = NET_CLASSES.includes(net.class) ? net.class : "signal"; el.netNodeRefInput.value = state.selectedPin?.ref ?? ""; el.netNodePinInput.value = state.selectedPin?.pin ?? ""; el.netNodesList.innerHTML = (net.nodes ?? []) .map( (node) => `
    ${node.ref}.${node.pin}
    ` ) .join(""); el.netEditor.classList.remove("hidden"); } function renderSelected() { if (!state.model) { el.selectedSummary.textContent = "Click a component, net, or pin to inspect it."; el.componentEditor.classList.add("hidden"); el.symbolEditor.classList.add("hidden"); el.pinEditor.classList.add("hidden"); el.netEditor.classList.add("hidden"); return; } if (state.selectedPin) { const nets = (state.model.nets ?? []) .filter((n) => n.nodes.some((x) => x.ref === state.selectedPin.ref && x.pin === state.selectedPin.pin)) .map((n) => n.name); el.selectedSummary.textContent = `${state.selectedPin.ref}.${state.selectedPin.pin}\nNets: ${nets.length ? nets.join(", ") : "(no net)"}`; el.componentEditor.classList.add("hidden"); el.symbolEditor.classList.add("hidden"); renderPinEditor(); el.netEditor.classList.add("hidden"); return; } if (state.selectedRefs.length > 1) { el.selectedSummary.textContent = `${state.selectedRefs.length} components selected\n${state.selectedRefs.slice(0, 10).join(", ")}${state.selectedRefs.length > 10 ? "..." : ""}`; el.componentEditor.classList.add("hidden"); el.symbolEditor.classList.add("hidden"); el.pinEditor.classList.add("hidden"); el.netEditor.classList.add("hidden"); return; } const inst = state.model.instances.find((i) => i.ref === state.selectedRef); if (inst) { el.selectedSummary.textContent = `${inst.ref} (${inst.symbol})`; el.componentEditor.classList.remove("hidden"); el.xInput.value = String(inst.placement.x ?? 0); el.yInput.value = String(inst.placement.y ?? 0); el.rotationInput.value = String((Math.round(Number(inst.placement.rotation ?? 0) / 90) * 90 + 360) % 360); el.lockedInput.checked = Boolean(inst.placement.locked); el.instRefInput.value = inst.ref; el.instValueInput.value = String(inst.properties?.value ?? ""); el.instNotesInput.value = String(inst.properties?.notes ?? ""); renderSymbolEditorForRef(inst.ref); el.pinEditor.classList.add("hidden"); el.netEditor.classList.add("hidden"); return; } if (state.selectedNet) { const net = state.model.nets.find((n) => n.name === state.selectedNet); if (net) { el.selectedSummary.textContent = `${net.name} (${net.class})\nNodes: ${net.nodes.map((n) => `${n.ref}.${n.pin}`).join(", ")}`; el.componentEditor.classList.add("hidden"); el.symbolEditor.classList.add("hidden"); el.pinEditor.classList.add("hidden"); renderNetEditor(); return; } } el.selectedSummary.textContent = "Click a component, net, or pin to inspect it."; el.componentEditor.classList.add("hidden"); el.symbolEditor.classList.add("hidden"); el.pinEditor.classList.add("hidden"); el.netEditor.classList.add("hidden"); } function renderIssues() { const errors = state.compile?.errors ?? []; const warnings = state.compile?.warnings ?? []; if (!errors.length && !warnings.length) { el.issues.innerHTML = "No issues.\nClick a net/component to inspect relationships."; return; } const rows = [ ...errors.map( (issue) => `
    [E] ${issue.message}
    ${issue.code} · ${issue.path ?? "-"}
    ${issue.suggestion ?? ""}
    ` ), ...warnings.map( (issue) => `
    [W] ${issue.message}
    ${issue.code} · ${issue.path ?? "-"}
    ${issue.suggestion ?? ""}
    ` ) ]; el.issues.innerHTML = rows.join(""); } function renderTopology() { const t = state.compile?.topology; if (!t) { el.topology.textContent = "No topology."; return; } const lines = []; lines.push("Power domains:"); for (const pd of t.power_domain_consumers ?? []) { lines.push(`- ${pd.name}: ${pd.count} consumers`); } lines.push(`Clock sources: ${(t.clock_sources ?? []).join(", ") || "-"}`); lines.push(`Clock sinks: ${(t.clock_sinks ?? []).join(", ") || "-"}`); lines.push("Buses:"); if (t.buses?.length) { for (const b of t.buses) { lines.push(`- ${b.name}: ${b.nets.join(", ")}`); } } else { lines.push("- none"); } lines.push("Signal paths:"); if (t.signal_paths?.length) { for (const p of t.signal_paths) { lines.push(`- ${p.join(" -> ")}`); } } else { lines.push("- none"); } el.topology.textContent = lines.join("\n"); } function parsePinNets(node) { const raw = node.getAttribute("data-pin-nets") ?? ""; if (!raw) { return []; } return raw.split(",").filter(Boolean); } function applyVisualHighlight() { const svg = el.canvasInner.querySelector("svg"); if (!svg) { return; } const activeNets = activeNetSet(); const isolateByNet = state.isolateNet && state.selectedNet; const selectedRefs = selectedRefSet(); const isolateByComp = state.isolateComponent && selectedRefs.size > 0; const compIncident = new Set(); for (const ref of selectedRefs) { for (const net of incidentNetsForRef(ref)) { compIncident.add(net); } } const netRefs = state.selectedNet ? refsConnectedToNet(state.selectedNet) : new Set(); svg.querySelectorAll("[data-net], [data-net-label], [data-net-junction], [data-net-tie]").forEach((node) => { const net = node.getAttribute("data-net") ?? node.getAttribute("data-net-label") ?? node.getAttribute("data-net-junction") ?? node.getAttribute("data-net-tie"); let on = true; if (isolateByNet) { on = net === state.selectedNet; } else if (isolateByComp) { on = compIncident.has(net); } else if (activeNets) { on = activeNets.has(net); } node.style.opacity = on ? "1" : activeNets || isolateByNet || isolateByComp ? "0.12" : "1"; }); svg.querySelectorAll("[data-ref]").forEach((node) => { const ref = node.getAttribute("data-ref"); let on = true; if (isolateByComp) { on = selectedRefs.has(ref); } else if (isolateByNet) { on = netRefs.has(ref); } node.style.opacity = on ? "1" : "0.1"; if (selectedRefs.has(ref)) { const rect = node.querySelector("rect"); if (rect) { rect.setAttribute("stroke", "#155eef"); rect.setAttribute("stroke-width", "2.6"); } } else { const rect = node.querySelector("rect"); if (rect) { rect.setAttribute("stroke", "#1f2937"); rect.setAttribute("stroke-width", "2"); } } }); svg.querySelectorAll("[data-pin-ref]").forEach((node) => { const ref = node.getAttribute("data-pin-ref"); const pinNets = parsePinNets(node); let on = true; if (isolateByComp) { on = selectedRefs.has(ref); } else if (isolateByNet) { on = pinNets.includes(state.selectedNet); } else if (activeNets) { on = pinNets.some((n) => activeNets.has(n)); } if (state.selectedPin && state.selectedPin.ref === ref && state.selectedPin.pin === node.getAttribute("data-pin-name")) { node.setAttribute("r", "4.2"); node.setAttribute("fill", "#155eef"); node.style.opacity = "1"; } else { node.setAttribute("r", "3.2"); node.setAttribute("fill", "#111827"); node.style.opacity = on ? "1" : activeNets || isolateByNet || isolateByComp ? "0.16" : "1"; } }); setLabelLayerVisibility(); } function flashElements(selector) { const nodes = [...el.canvasInner.querySelectorAll(selector)]; for (const n of nodes) { n.classList.add("flash"); } setTimeout(() => { for (const n of nodes) { n.classList.remove("flash"); } }, 1200); } function focusIssue(issueId) { const target = state.compile?.focus_map?.[issueId]; if (!target) { return; } if (target.type === "net" && target.net) { state.selectedNet = target.net; setSelectedRefs([]); state.selectedPin = null; renderAll(); flashElements(`[data-net="${target.net}"], [data-net-label="${target.net}"], [data-net-junction="${target.net}"], [data-net-tie="${target.net}"]`); } else if (target.type === "component" && target.ref) { selectSingleRef(target.ref); state.selectedNet = null; state.selectedPin = null; renderAll(); flashElements(`[data-ref="${target.ref}"]`); } else if (target.type === "pin" && target.ref && target.pin) { selectSingleRef(target.ref); state.selectedNet = null; state.selectedPin = { ref: target.ref, pin: target.pin, nets: [] }; renderAll(); flashElements(`[data-pin-ref="${target.ref}"][data-pin-name="${target.pin}"]`); } if (target.bbox) { zoomToBBox(target.bbox); } } function showPinTooltip(clientX, clientY, ref, pin, nets) { el.pinTooltip.innerHTML = `${ref}.${pin}
    ${nets.length ? `Net: ${nets.join(", ")}` : "Net: (unconnected)"}`; el.pinTooltip.classList.remove("hidden"); const rect = el.canvasViewport.getBoundingClientRect(); el.pinTooltip.style.left = `${clientX - rect.left + 14}px`; el.pinTooltip.style.top = `${clientY - rect.top + 14}px`; } function hidePinTooltip() { el.pinTooltip.classList.add("hidden"); } function bindSvgInteractions() { const svg = el.canvasInner.querySelector("svg"); if (!svg) { return; } svg.querySelectorAll("[data-ref]").forEach((node) => { node.addEventListener("pointerdown", (evt) => { evt.stopPropagation(); evt.preventDefault(); const ref = node.getAttribute("data-ref"); if (!ref || !state.model) { return; } if (hasSelectionModifier(evt)) { toggleSelectedRef(ref); state.selectedNet = null; state.selectedPin = null; renderAll(); return; } if (!state.selectedRefs.includes(ref)) { selectSingleRef(ref); } state.selectedNet = null; state.selectedPin = null; renderInstances(); renderNets(); renderSelected(); applyVisualHighlight(); const inst = state.model.instances.find((x) => x.ref === ref); if (!inst) { return; } const pt = canvasToSvgPoint(evt.clientX, evt.clientY); const dragRefs = state.selectedRefs.length ? [...state.selectedRefs] : [ref]; const baseByRef = {}; for (const r of dragRefs) { const ii = state.model.instances.find((x) => x.ref === r); if (!ii) { continue; } baseByRef[r] = { x: Number(ii.placement.x ?? 0), y: Number(ii.placement.y ?? 0) }; } state.draggingComponentRef = ref; state.dragPointerId = evt.pointerId; state.dragComponent = { startPointerX: pt.x, startPointerY: pt.y, refs: dragRefs, baseByRef, pendingByRef: clone(baseByRef) }; state.dragPreviewNode = node; state.dragMoved = false; node.setPointerCapture(evt.pointerId); }); }); svg.querySelectorAll("[data-net], [data-net-label], [data-net-junction], [data-net-tie]").forEach((node) => { node.addEventListener("click", (evt) => { evt.stopPropagation(); const net = node.getAttribute("data-net") ?? node.getAttribute("data-net-label") ?? node.getAttribute("data-net-junction") ?? node.getAttribute("data-net-tie"); state.selectedNet = net; setSelectedRefs([]); state.selectedPin = null; renderAll(); }); }); svg.querySelectorAll("[data-pin-ref]").forEach((node) => { node.addEventListener("mouseenter", (evt) => { const ref = node.getAttribute("data-pin-ref"); const pin = node.getAttribute("data-pin-name"); const nets = parsePinNets(node); showPinTooltip(evt.clientX, evt.clientY, ref, pin, nets); }); node.addEventListener("mousemove", (evt) => { const ref = node.getAttribute("data-pin-ref"); const pin = node.getAttribute("data-pin-name"); const nets = parsePinNets(node); showPinTooltip(evt.clientX, evt.clientY, ref, pin, nets); }); node.addEventListener("mouseleave", hidePinTooltip); node.addEventListener("click", (evt) => { evt.stopPropagation(); const ref = node.getAttribute("data-pin-ref"); const pin = node.getAttribute("data-pin-name"); const nets = parsePinNets(node); state.selectedPin = { ref, pin, nets }; selectSingleRef(ref); state.selectedNet = null; renderAll(); }); }); } function renderCanvas() { if (!state.compile?.svg) { el.canvasInner.innerHTML = ""; return; } el.canvasInner.innerHTML = state.compile.svg; bindSvgInteractions(); applyVisualHighlight(); updateTransform(); } function renderAll() { renderInstances(); renderNets(); renderSelected(); renderIssues(); renderTopology(); renderCanvas(); el.isolateNetBtn.classList.toggle("activeChip", state.isolateNet); el.isolateComponentBtn.classList.toggle("activeChip", state.isolateComponent); } async function compileModel(model, opts = {}) { if (state.compileDebounceId != null) { clearTimeout(state.compileDebounceId); state.compileDebounceId = null; } const source = opts.source ?? "manual"; const fit = opts.fit ?? false; const keepView = opts.keepView ?? false; setStatus(source === "drag" ? "Compiling after drag..." : "Compiling..."); try { const result = await apiPost("/compile", { payload: model, options: compileOptions() }); state.model = applyCompileLayoutToModel(model, result); state.compile = result; reconcileSelectionWithModel(); refreshJsonEditor(); saveSnapshot(); renderAll(); if (fit) { fitView(result.layout); } else if (!keepView && !state.userAdjustedView) { fitView(result.layout); } const m = result.layout_metrics; setStatus( `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings} crossings, ${m.overlap_edges} overlaps)` ); } catch (err) { setStatus(`Compile failed: ${err.message}`, false); el.issues.textContent = `Compile error: ${err.message}`; } } function clearDragPreview() { if (state.dragComponent?.refs?.length) { for (const ref of state.dragComponent.refs) { const n = el.canvasInner.querySelector(`[data-ref="${ref}"]`); if (n) { n.removeAttribute("transform"); } } } else if (state.dragPreviewNode) { state.dragPreviewNode.removeAttribute("transform"); } state.dragPreviewNode = null; } function queueCompile(keepView = true, source = "edit") { if (!state.model) { return; } if (state.compileDebounceId != null) { clearTimeout(state.compileDebounceId); } state.compileDebounceId = setTimeout(() => { state.compileDebounceId = null; compileModel(state.model, { keepView, source }); }, 150); } function updateInstance(ref, patch) { const inst = instanceByRef(ref); if (!inst) { return false; } Object.assign(inst, patch); return true; } function setPinUi(ref, pinName, patch) { const inst = instanceByRef(ref); if (!inst) { return false; } inst.properties = inst.properties ?? {}; const pinUiMap = inst.properties.pin_ui && typeof inst.properties.pin_ui === "object" && !Array.isArray(inst.properties.pin_ui) ? inst.properties.pin_ui : {}; const pinUiEntry = pinUiMap[pinName] && typeof pinUiMap[pinName] === "object" && !Array.isArray(pinUiMap[pinName]) ? pinUiMap[pinName] : {}; pinUiMap[pinName] = { ...pinUiEntry, ...patch }; inst.properties.pin_ui = pinUiMap; return true; } function hasNodeOnNet(net, ref, pin) { return (net.nodes ?? []).some((n) => n.ref === ref && n.pin === pin); } function connectPinToNet(ref, pin, netName, opts = {}) { if (!state.model) { return { ok: false, message: "No model loaded." }; } const inst = instanceByRef(ref); if (!inst) { return { ok: false, message: `Unknown component '${ref}'.` }; } if (!pinExists(ref, pin)) { const sym = symbolForRef(ref); const category = String(sym?.category ?? "").toLowerCase(); const genericLike = sym?.auto_generated === true || category.includes("generic") || inst.part === "generic"; if (!genericLike) { return { ok: false, message: `Unknown pin ${ref}.${pin}.` }; } } const normalized = normalizeNetName(netName); if (!normalized) { return { ok: false, message: "Net name is required." }; } let net = netByName(normalized); if (!net) { const guessedClass = opts.netClass && NET_CLASSES.includes(opts.netClass) ? opts.netClass : inferClassForPin(ref, pin); net = { name: normalized, class: guessedClass, nodes: [] }; state.model.nets.push(net); } if (!hasNodeOnNet(net, ref, pin)) { net.nodes.push({ ref, pin }); } return { ok: true, net: normalized }; } function removeNetIfOrphaned(netName) { if (!state.model) { return; } const net = netByName(netName); if (!net) { return; } if ((net.nodes?.length ?? 0) < 2) { state.model.nets = state.model.nets.filter((n) => n.name !== netName); if (state.selectedNet === netName) { state.selectedNet = null; } } } function disconnectPinFromNet(ref, pin, netName) { const net = netByName(netName); if (!net) { return { ok: false, message: `Net '${netName}' not found.` }; } const before = net.nodes.length; net.nodes = net.nodes.filter((n) => !(n.ref === ref && n.pin === pin)); if (net.nodes.length === before) { return { ok: false, message: `${ref}.${pin} is not connected to ${netName}.` }; } removeNetIfOrphaned(netName); return { ok: true }; } function renameNet(oldName, newName) { if (!state.model) { return { ok: false, message: "No model loaded." }; } const current = netByName(oldName); if (!current) { return { ok: false, message: `Net '${oldName}' not found.` }; } const normalized = normalizeNetName(newName); if (!normalized) { return { ok: false, message: "Net name is required." }; } if (normalized !== oldName && netByName(normalized)) { return { ok: false, message: `Net '${normalized}' already exists.` }; } current.name = normalized; if (state.selectedNet === oldName) { state.selectedNet = normalized; } return { ok: true, name: normalized }; } function setNetClass(netName, netClass) { const net = netByName(netName); if (!net) { return { ok: false, message: `Net '${netName}' not found.` }; } if (!NET_CLASSES.includes(netClass)) { return { ok: false, message: `Invalid net class '${netClass}'.` }; } net.class = netClass; return { ok: true }; } function isTypingContext(target) { if (!target || !(target instanceof HTMLElement)) { return false; } const tag = target.tagName.toLowerCase(); return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; } function parseJsonPositionError(text, err) { const msg = String(err?.message ?? "Invalid JSON"); const m = /position\s+(\d+)/i.exec(msg); if (!m) { return { message: msg, line: null, col: null }; } const pos = Number(m[1]); let line = 1; let col = 1; for (let i = 0; i < Math.min(pos, text.length); i += 1) { if (text[i] === "\n") { line += 1; col = 1; } else { col += 1; } } return { message: msg, line, col }; } function sortKeysDeep(value) { if (Array.isArray(value)) { return value.map(sortKeysDeep); } if (value && typeof value === "object") { const out = {}; for (const key of Object.keys(value).sort()) { out[key] = sortKeysDeep(value[key]); } return out; } return value; } async function loadSchemaText() { if (state.schemaText) { return state.schemaText; } const res = await fetch(SCHEMA_URL); if (!res.ok) { throw new Error("Schema file unavailable."); } const text = await res.text(); state.schemaText = text; return text; } async function openSchemaModal() { try { const raw = await loadSchemaText(); let pretty = raw; try { pretty = JSON.stringify(JSON.parse(raw), null, 2); } catch {} el.schemaViewer.value = pretty; el.schemaModal.classList.remove("hidden"); el.schemaViewer.focus(); el.jsonFeedback.textContent = "Schema loaded."; } catch (err) { el.jsonFeedback.textContent = `Schema load failed: ${err.message}`; } } function closeSchemaModal() { el.schemaModal.classList.add("hidden"); } function buildMinimalRepro(model) { if (!state.selectedRefs.length && !state.selectedNet) { return model; } const refs = new Set(); const nets = []; if (state.selectedNet) { const net = model.nets.find((n) => n.name === state.selectedNet); if (net) { nets.push(net); net.nodes.forEach((n) => refs.add(n.ref)); } } if (state.selectedRefs.length) { for (const ref of state.selectedRefs) { refs.add(ref); for (const net of model.nets) { if (net.nodes.some((n) => n.ref === ref)) { nets.push(net); net.nodes.forEach((n) => refs.add(n.ref)); } } } } const uniqNet = new Map(nets.map((n) => [n.name, n])); const instances = model.instances.filter((i) => refs.has(i.ref)); const symbols = {}; for (const inst of instances) { symbols[inst.symbol] = model.symbols[inst.symbol]; } return { meta: { ...(model.meta ?? {}), title: `${(model.meta?.title ?? "schemeta")} - minimal repro` }, symbols, instances, nets: [...uniqNet.values()], constraints: {}, annotations: [] }; } function summarizeModelDelta(before, after) { if (!before) { return "Applied JSON (new project loaded)."; } const beforeRefs = new Set((before.instances ?? []).map((i) => i.ref)); const afterRefs = new Set((after.instances ?? []).map((i) => i.ref)); const beforeNets = new Set((before.nets ?? []).map((n) => n.name)); const afterNets = new Set((after.nets ?? []).map((n) => n.name)); const beforeSymbols = new Set(Object.keys(before.symbols ?? {})); const afterSymbols = new Set(Object.keys(after.symbols ?? {})); const countOnlyIn = (a, b) => [...a].filter((x) => !b.has(x)).length; const instAdded = countOnlyIn(afterRefs, beforeRefs); const instRemoved = countOnlyIn(beforeRefs, afterRefs); const netAdded = countOnlyIn(afterNets, beforeNets); const netRemoved = countOnlyIn(beforeNets, afterNets); const symAdded = countOnlyIn(afterSymbols, beforeSymbols); const symRemoved = countOnlyIn(beforeSymbols, afterSymbols); const beforeByRef = new Map((before.instances ?? []).map((i) => [i.ref, i])); let moved = 0; for (const inst of after.instances ?? []) { const prev = beforeByRef.get(inst.ref); if (!prev) { continue; } const px = Number(prev.placement?.x ?? 0); const py = Number(prev.placement?.y ?? 0); const nx = Number(inst.placement?.x ?? 0); const ny = Number(inst.placement?.y ?? 0); if (px !== nx || py !== ny || Boolean(prev.placement?.locked) !== Boolean(inst.placement?.locked)) { moved += 1; } } const beforeNetByName = new Map((before.nets ?? []).map((n) => [n.name, n])); let netChanged = 0; for (const net of after.nets ?? []) { const prev = beforeNetByName.get(net.name); if (!prev) { continue; } const prevSig = JSON.stringify({ class: prev.class, nodes: [...(prev.nodes ?? [])].map((n) => `${n.ref}.${n.pin}`).sort() }); const nextSig = JSON.stringify({ class: net.class, nodes: [...(net.nodes ?? [])].map((n) => `${n.ref}.${n.pin}`).sort() }); if (prevSig !== nextSig) { netChanged += 1; } } const parts = []; if (instAdded || instRemoved || moved) { parts.push(`instances +${instAdded}/-${instRemoved}, moved ${moved}`); } if (netAdded || netRemoved || netChanged) { parts.push(`nets +${netAdded}/-${netRemoved}, changed ${netChanged}`); } if (symAdded || symRemoved) { parts.push(`symbols +${symAdded}/-${symRemoved}`); } return parts.length ? `Applied JSON (${parts.join(" | ")}).` : "Applied JSON (no structural changes)."; } async function validateJsonEditor() { const text = el.jsonEditor.value; try { const parsed = JSON.parse(text); const out = await apiPost("/analyze", { payload: parsed }); el.jsonFeedback.textContent = `Validation: ${out.errors.length} errors, ${out.warnings.length} warnings.`; if (!out.errors.length && !out.warnings.length) { return; } const first = out.errors[0] ?? out.warnings[0]; el.jsonFeedback.textContent += ` First issue: ${first.message}`; } catch (err) { const p = parseJsonPositionError(text, err); if (p.line != null) { el.jsonFeedback.textContent = `JSON parse error at line ${p.line}, col ${p.col}: ${p.message}`; const lines = text.split("\n"); let idx = 0; for (let i = 0; i < p.line - 1; i += 1) { idx += lines[i].length + 1; } el.jsonEditor.focus(); el.jsonEditor.setSelectionRange(idx, idx + Math.max(1, lines[p.line - 1]?.length ?? 1)); } else { el.jsonFeedback.textContent = `JSON parse error: ${p.message}`; } } } async function runLayoutAction(path) { if (!state.model) { return; } setStatus(path.includes("auto") ? "Auto layout..." : "Auto tidy..."); try { const out = await apiPost(path, { payload: state.model, options: compileOptions() }); state.model = applyCompileLayoutToModel(out.model, out.compile); state.compile = out.compile; refreshJsonEditor(); renderAll(); fitView(out.compile.layout); saveSnapshot(); setStatus( `Compiled (${out.compile.errors.length}E, ${out.compile.warnings.length}W | ${out.compile.layout_metrics.crossings} crossings)` ); } catch (err) { setStatus(`Layout action failed: ${err.message}`, false); } } async function loadSample() { const res = await fetch("/sample.schemeta.json"); if (!res.ok) { setStatus("Sample missing.", false); return; } const model = await res.json(); setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; await compileModel(model, { fit: true }); } function setupEvents() { el.instanceFilter.addEventListener("input", renderInstances); el.netFilter.addEventListener("input", renderNets); el.instanceList.addEventListener("click", (evt) => { const item = evt.target.closest("[data-ref-item]"); if (!item) { return; } const ref = item.getAttribute("data-ref-item"); if (hasSelectionModifier(evt)) { toggleSelectedRef(ref); } else { selectSingleRef(ref); } state.selectedNet = null; state.selectedPin = null; renderAll(); }); el.netList.addEventListener("click", (evt) => { const item = evt.target.closest("[data-net-item]"); if (!item) { return; } state.selectedNet = item.getAttribute("data-net-item"); setSelectedRefs([]); state.selectedPin = null; renderAll(); }); el.issues.addEventListener("click", (evt) => { const row = evt.target.closest("[data-issue-id]"); if (!row) { return; } focusIssue(row.getAttribute("data-issue-id")); }); el.updatePlacementBtn.addEventListener("click", async () => { if (state.selectedRefs.length !== 1) { el.jsonFeedback.textContent = "Select one component to edit."; return; } const inst = instanceByRef(state.selectedRef); if (!inst) { return; } const nextRef = normalizeRef(el.instRefInput.value); if (!nextRef) { el.jsonFeedback.textContent = "Ref cannot be empty."; return; } if (nextRef !== inst.ref && instanceByRef(nextRef)) { el.jsonFeedback.textContent = `Ref '${nextRef}' already exists.`; return; } const oldRef = inst.ref; updateInstance(oldRef, { ref: nextRef, placement: { ...inst.placement, x: toGrid(Number(el.xInput.value)), y: toGrid(Number(el.yInput.value)), rotation: Number(el.rotationInput.value), locked: el.lockedInput.checked }, properties: { ...(inst.properties ?? {}), value: el.instValueInput.value, notes: el.instNotesInput.value } }); if (oldRef !== nextRef) { for (const net of state.model.nets ?? []) { for (const node of net.nodes ?? []) { if (node.ref === oldRef) { node.ref = nextRef; } } } if (state.selectedPin?.ref === oldRef) { state.selectedPin.ref = nextRef; } selectSingleRef(nextRef); } el.jsonFeedback.textContent = "Component updated."; queueCompile(true, "component-edit"); }); el.rotateSelectedBtn.addEventListener("click", () => { if (state.selectedRefs.length !== 1 || !state.selectedRef) { return; } const inst = instanceByRef(state.selectedRef); if (!inst) { return; } const current = Number(inst.placement.rotation ?? 0); inst.placement.rotation = ((Math.round(current / 90) * 90 + 90) % 360 + 360) % 360; inst.placement.locked = true; renderSelected(); el.jsonFeedback.textContent = "Component rotated."; queueCompile(true, "rotate"); }); el.showPinNetLabelInput.addEventListener("change", () => { if (!state.selectedPin) { return; } const { ref, pin } = state.selectedPin; if (!setPinUi(ref, pin, { show_net_label: el.showPinNetLabelInput.checked })) { return; } el.jsonFeedback.textContent = `Updated ${ref}.${pin} label visibility.`; queueCompile(true, "pin-ui"); }); el.applyPinPropsBtn.addEventListener("click", () => { if (!state.selectedPin) { return; } const { ref, pin: oldPinName } = state.selectedPin; const inst = instanceByRef(ref); const sym = symbolForRef(ref); if (!inst || !sym) { return; } const idx = (sym.pins ?? []).findIndex((p) => p.name === oldPinName); if (idx < 0) { el.jsonFeedback.textContent = `Pin ${ref}.${oldPinName} not found on symbol.`; return; } const newName = String(el.pinNameInput.value ?? "").trim(); const newNumber = String(el.pinNumberInput.value ?? "").trim() || String(idx + 1); const newSide = el.pinSideInput.value; const newType = el.pinTypeInput.value; const newOffset = Number(el.pinOffsetInput.value); if (!newName) { el.jsonFeedback.textContent = "Pin name cannot be empty."; return; } if (!PIN_SIDES.includes(newSide) || !PIN_TYPES.includes(newType) || !Number.isFinite(newOffset) || newOffset < 0) { el.jsonFeedback.textContent = "Invalid pin side/type/offset."; return; } const duplicate = (sym.pins ?? []).some((p, i) => i !== idx && p.name === newName); if (duplicate) { el.jsonFeedback.textContent = `Pin name '${newName}' already exists on symbol '${inst.symbol}'.`; return; } const beforeName = sym.pins[idx].name; sym.pins[idx] = { ...sym.pins[idx], name: newName, number: newNumber, side: newSide, type: newType, offset: Math.round(newOffset) }; renamePinAcrossSymbolInstances(inst.symbol, beforeName, newName); state.selectedPin = { ...state.selectedPin, pin: newName }; el.jsonFeedback.textContent = `Updated pin ${ref}.${newName}.`; queueCompile(true, "pin-props"); }); el.connectPinBtn.addEventListener("click", () => { if (!state.selectedPin) { return; } const netName = el.pinNetSelect.value; if (!netName) { el.jsonFeedback.textContent = "Choose a net first."; return; } const out = connectPinToNet(state.selectedPin.ref, state.selectedPin.pin, netName); if (!out.ok) { el.jsonFeedback.textContent = out.message; return; } state.selectedNet = out.net; el.jsonFeedback.textContent = `Connected ${state.selectedPin.ref}.${state.selectedPin.pin} to ${out.net}.`; queueCompile(true, "connect-pin"); }); el.createConnectNetBtn.addEventListener("click", () => { if (!state.selectedPin) { return; } const name = normalizeNetName(el.newNetNameInput.value || el.newNetNameInput.placeholder || nextAutoNetName()); const cls = el.newNetClassInput.value; const out = connectPinToNet(state.selectedPin.ref, state.selectedPin.pin, name, { netClass: cls }); if (!out.ok) { el.jsonFeedback.textContent = out.message; return; } state.selectedNet = out.net; el.newNetNameInput.value = ""; el.jsonFeedback.textContent = `Created and connected net ${out.net}.`; queueCompile(true, "create-net"); }); el.pinConnections.addEventListener("click", (evt) => { const btn = evt.target.closest("[data-disconnect-net]"); if (!btn || !state.selectedPin) { return; } const netName = btn.getAttribute("data-disconnect-net"); const out = disconnectPinFromNet(state.selectedPin.ref, state.selectedPin.pin, netName); if (!out.ok) { el.jsonFeedback.textContent = out.message; return; } el.jsonFeedback.textContent = `Disconnected ${state.selectedPin.ref}.${state.selectedPin.pin} from ${netName}.`; queueCompile(true, "disconnect-pin"); }); el.updateNetBtn.addEventListener("click", () => { if (!state.selectedNet) { return; } const oldName = state.selectedNet; const renamed = renameNet(oldName, el.netNameInput.value); if (!renamed.ok) { el.jsonFeedback.textContent = renamed.message; return; } const clsOut = setNetClass(renamed.name, el.netClassInput.value); if (!clsOut.ok) { el.jsonFeedback.textContent = clsOut.message; return; } state.selectedNet = renamed.name; el.jsonFeedback.textContent = `Updated net ${renamed.name}.`; queueCompile(true, "net-edit"); }); el.addNetNodeBtn.addEventListener("click", () => { if (!state.selectedNet) { return; } const ref = normalizeRef(el.netNodeRefInput.value); const pin = String(el.netNodePinInput.value ?? "").trim(); if (!ref || !pin) { el.jsonFeedback.textContent = "Provide both ref and pin."; return; } const out = connectPinToNet(ref, pin, state.selectedNet); if (!out.ok) { el.jsonFeedback.textContent = out.message; return; } el.netNodePinInput.value = ""; el.jsonFeedback.textContent = `Added ${ref}.${pin} to ${state.selectedNet}.`; queueCompile(true, "net-node-add"); }); el.netNodesList.addEventListener("click", (evt) => { const btn = evt.target.closest("[data-remove-node]"); if (!btn || !state.selectedNet) { return; } const netName = state.selectedNet; const [ref, ...pinParts] = String(btn.getAttribute("data-remove-node")).split("."); const pin = pinParts.join("."); const out = disconnectPinFromNet(ref, pin, netName); if (!out.ok) { el.jsonFeedback.textContent = out.message; return; } el.jsonFeedback.textContent = `Removed ${ref}.${pin} from ${netName}.`; queueCompile(true, "net-node-remove"); }); el.addSymbolPinBtn.addEventListener("click", () => { const row = { name: `P${el.symbolPinsList.querySelectorAll(".symbolPinRow").length + 1}`, number: `${el.symbolPinsList.querySelectorAll(".symbolPinRow").length + 1}`, side: "left", offset: 20, type: "passive" }; el.symbolPinsList.insertAdjacentHTML("beforeend", symbolPinRowHtml(row)); }); el.symbolPinsList.addEventListener("click", (evt) => { const btn = evt.target.closest("[data-remove-symbol-pin]"); if (!btn) { return; } const row = btn.closest(".symbolPinRow"); if (row) { row.remove(); } }); el.applySymbolBtn.addEventListener("click", () => { if (!state.selectedRef) { return; } const inst = instanceByRef(state.selectedRef); const sym = symbolForRef(state.selectedRef); if (!inst || !sym) { return; } const nextCategory = String(el.symbolCategoryInput.value ?? "").trim() || String(sym.category ?? "generic"); const nextWidth = Number(el.symbolWidthInput.value); const nextHeight = Number(el.symbolHeightInput.value); if (!Number.isFinite(nextWidth) || !Number.isFinite(nextHeight) || nextWidth < 20 || nextHeight < 20) { el.jsonFeedback.textContent = "Symbol width/height must be >= 20."; return; } const rows = [...el.symbolPinsList.querySelectorAll(".symbolPinRow")]; if (!rows.length) { el.jsonFeedback.textContent = "Symbol must have at least one pin."; return; } const parsedPins = []; for (const row of rows) { const name = String(row.querySelector(".pinName")?.value ?? "").trim(); const number = String(row.querySelector(".pinNumber")?.value ?? "").trim(); const side = String(row.querySelector(".pinSide")?.value ?? ""); const offset = Number(row.querySelector(".pinOffset")?.value ?? 0); const type = String(row.querySelector(".pinType")?.value ?? ""); if (!name || !number || !PIN_SIDES.includes(side) || !PIN_TYPES.includes(type) || !Number.isFinite(offset) || offset < 0) { el.jsonFeedback.textContent = "Invalid symbol pin row values."; return; } parsedPins.push({ oldName: row.getAttribute("data-old-pin") ?? name, pin: { name, number, side, offset: Math.round(offset), type } }); } const unique = new Set(parsedPins.map((p) => p.pin.name)); if (unique.size !== parsedPins.length) { el.jsonFeedback.textContent = "Duplicate pin names are not allowed."; return; } for (const entry of parsedPins) { if (entry.oldName && entry.oldName !== entry.pin.name) { renamePinAcrossSymbolInstances(inst.symbol, entry.oldName, entry.pin.name); } } sym.category = nextCategory; sym.body = { ...(sym.body ?? {}), width: Math.round(nextWidth), height: Math.round(nextHeight) }; sym.pins = parsedPins.map((p) => p.pin); const allowedPins = new Set(sym.pins.map((p) => p.name)); const refs = new Set((state.model.instances ?? []).filter((i) => i.symbol === inst.symbol).map((i) => i.ref)); for (const net of state.model.nets ?? []) { net.nodes = (net.nodes ?? []).filter((node) => !refs.has(node.ref) || allowedPins.has(node.pin)); } state.model.nets = (state.model.nets ?? []).filter((net) => (net.nodes ?? []).length >= 2); if (state.selectedPin && !pinExists(state.selectedPin.ref, state.selectedPin.pin)) { state.selectedPin = null; } el.jsonFeedback.textContent = `Updated symbol ${inst.symbol}.`; queueCompile(true, "symbol-edit"); }); el.zoomInBtn.addEventListener("click", () => { state.scale = Math.min(4, state.scale + 0.1); state.userAdjustedView = true; updateTransform(); }); el.zoomOutBtn.addEventListener("click", () => { state.scale = Math.max(0.2, state.scale - 0.1); state.userAdjustedView = true; updateTransform(); }); el.zoomResetBtn.addEventListener("click", () => { state.scale = 1; state.panX = 40; state.panY = 40; state.userAdjustedView = true; updateTransform(); }); el.fitViewBtn.addEventListener("click", () => { if (state.compile?.layout) { fitView(state.compile.layout); } }); el.showLabelsInput.addEventListener("change", () => { state.showLabels = el.showLabelsInput.checked; setLabelLayerVisibility(); }); el.renderModeSelect.addEventListener("change", async () => { state.renderMode = el.renderModeSelect.value; if (state.model) { await compileModel(state.model, { keepView: true }); } }); el.isolateNetBtn.addEventListener("click", () => { state.isolateNet = !state.isolateNet; renderAll(); }); el.isolateComponentBtn.addEventListener("click", () => { state.isolateComponent = !state.isolateComponent; renderAll(); }); el.canvasViewport.addEventListener( "wheel", (evt) => { evt.preventDefault(); const oldScale = state.scale; state.scale = Math.min(4, Math.max(0.2, state.scale + (evt.deltaY < 0 ? 0.08 : -0.08))); const rect = el.canvasViewport.getBoundingClientRect(); const px = evt.clientX - rect.left; const py = evt.clientY - rect.top; state.panX = px - (px - state.panX) * (state.scale / oldScale); state.panY = py - (py - state.panY) * (state.scale / oldScale); state.userAdjustedView = true; updateTransform(); }, { passive: false } ); el.canvasViewport.addEventListener("pointerdown", (evt) => { const interactive = evt.target.closest( "[data-ref], [data-net], [data-net-label], [data-net-junction], [data-net-tie], [data-pin-ref]" ); if (!interactive && evt.button === 0 && !state.spacePan) { beginBoxSelection(evt.clientX, evt.clientY); return; } const allowPan = evt.button === 1 || (evt.button === 0 && state.spacePan); if (!allowPan) { return; } if (state.draggingComponentRef) { return; } state.isPanning = true; state.panStartX = evt.clientX; state.panStartY = evt.clientY; state.basePanX = state.panX; state.basePanY = state.panY; el.canvasViewport.classList.add("dragging"); }); el.canvasViewport.addEventListener("click", (evt) => { if (state.suppressCanvasClick) { state.suppressCanvasClick = false; return; } const interactive = evt.target.closest( "[data-ref], [data-net], [data-net-label], [data-net-junction], [data-net-tie], [data-pin-ref]" ); if (interactive) { return; } const hadSelection = Boolean(state.selectedRefs.length || state.selectedNet || state.selectedPin); const hadIsolation = Boolean(state.isolateNet || state.isolateComponent); if (!hadSelection && !hadIsolation) { return; } setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; state.isolateNet = false; state.isolateComponent = false; renderAll(); }); window.addEventListener("pointermove", (evt) => { if (state.boxSelecting) { updateBoxSelection(evt.clientX, evt.clientY); return; } if (state.draggingComponentRef && state.dragComponent && state.model) { if (state.dragPointerId != null && evt.pointerId !== state.dragPointerId) { return; } const pt = canvasToSvgPoint(evt.clientX, evt.clientY); const dx = pt.x - state.dragComponent.startPointerX; const dy = pt.y - state.dragComponent.startPointerY; setSelectedRefs(state.dragComponent.refs ?? [state.draggingComponentRef]); state.selectedPin = null; for (const ref of state.dragComponent.refs ?? []) { const base = state.dragComponent.baseByRef?.[ref]; if (!base) { continue; } const nextX = toGrid(base.x + dx); const nextY = toGrid(base.y + dy); const moveX = nextX - base.x; const moveY = nextY - base.y; state.dragComponent.pendingByRef[ref] = { x: nextX, y: nextY }; if (moveX !== 0 || moveY !== 0) { state.dragMoved = true; } const n = el.canvasInner.querySelector(`[data-ref="${ref}"]`); if (n) { n.setAttribute("transform", `translate(${moveX} ${moveY})`); } } const primary = state.draggingComponentRef; const p = state.dragComponent.pendingByRef?.[primary]; if (p) { el.xInput.value = String(p.x); el.yInput.value = String(p.y); } return; } if (!state.isPanning) { return; } state.panX = state.basePanX + (evt.clientX - state.panStartX); state.panY = state.basePanY + (evt.clientY - state.panStartY); state.userAdjustedView = true; updateTransform(); }); window.addEventListener("pointerup", async (evt) => { if (state.dragPointerId != null && evt.pointerId !== state.dragPointerId) { return; } finishBoxSelection(); state.isPanning = false; el.canvasViewport.classList.remove("dragging"); const moved = state.dragMoved; const wasDragging = Boolean(state.draggingComponentRef); const dragSnapshot = state.dragComponent ? clone(state.dragComponent.pendingByRef ?? {}) : null; clearDragPreview(); state.draggingComponentRef = null; state.dragPointerId = null; state.dragComponent = null; state.dragMoved = false; if (wasDragging && moved && state.model) { for (const [ref, pos] of Object.entries(dragSnapshot ?? {})) { const inst = state.model.instances.find((x) => x.ref === ref); if (inst) { inst.placement.x = pos.x; inst.placement.y = pos.y; inst.placement.locked = true; } } await compileModel(state.model, { source: "drag", keepView: true }); } }); window.addEventListener("pointercancel", () => { finishBoxSelection(); clearDragPreview(); state.draggingComponentRef = null; state.dragPointerId = null; state.dragComponent = null; state.dragMoved = false; state.isPanning = false; el.canvasViewport.classList.remove("dragging"); }); window.addEventListener("keydown", (evt) => { if (evt.code === "Space") { if (isTypingContext(evt.target)) { return; } if (state.selectedRefs.length && state.model && !evt.repeat) { for (const ref of state.selectedRefs) { const inst = state.model.instances.find((x) => x.ref === ref); if (!inst) { continue; } const current = Number(inst.placement.rotation ?? 0); inst.placement.rotation = ((Math.round(current / 90) * 90 + 90) % 360 + 360) % 360; inst.placement.locked = true; } compileModel(state.model, { source: "rotate", keepView: true }); evt.preventDefault(); return; } state.spacePan = true; el.canvasViewport.classList.add("dragging"); evt.preventDefault(); } }); window.addEventListener("keyup", (evt) => { if (evt.code === "Space") { state.spacePan = false; if (!state.isPanning) { el.canvasViewport.classList.remove("dragging"); } } if (evt.code === "Escape") { closeSchemaModal(); } }); el.showSchemaBtn.addEventListener("click", openSchemaModal); el.closeSchemaBtn.addEventListener("click", closeSchemaModal); el.schemaModal.addEventListener("click", (evt) => { if (evt.target === el.schemaModal) { closeSchemaModal(); } }); el.copySchemaBtn.addEventListener("click", async () => { try { const text = el.schemaViewer.value || (await loadSchemaText()); await navigator.clipboard.writeText(text); el.jsonFeedback.textContent = "Schema copied."; } catch (err) { el.jsonFeedback.textContent = `Schema copy failed: ${err.message}`; } }); el.downloadSchemaBtn.addEventListener("click", async () => { try { const text = el.schemaViewer.value || (await loadSchemaText()); const blob = new Blob([text], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "schemeta.schema.json"; document.body.appendChild(a); a.click(); URL.revokeObjectURL(a.href); document.body.removeChild(a); el.jsonFeedback.textContent = "Schema downloaded."; } catch (err) { el.jsonFeedback.textContent = `Schema download failed: ${err.message}`; } }); el.validateJsonBtn.addEventListener("click", validateJsonEditor); el.formatJsonBtn.addEventListener("click", () => { try { const parsed = JSON.parse(el.jsonEditor.value); el.jsonEditor.value = JSON.stringify(parsed, null, 2); el.jsonFeedback.textContent = "JSON formatted."; } catch (err) { const p = parseJsonPositionError(el.jsonEditor.value, err); el.jsonFeedback.textContent = `Format failed: ${p.message}`; } }); el.sortJsonBtn.addEventListener("click", () => { try { const parsed = JSON.parse(el.jsonEditor.value); el.jsonEditor.value = JSON.stringify(sortKeysDeep(parsed), null, 2); el.jsonFeedback.textContent = "Keys sorted recursively."; } catch (err) { const p = parseJsonPositionError(el.jsonEditor.value, err); el.jsonFeedback.textContent = `Sort failed: ${p.message}`; } }); el.copyReproBtn.addEventListener("click", async () => { try { const model = JSON.parse(el.jsonEditor.value); const repro = buildMinimalRepro(model); await navigator.clipboard.writeText(JSON.stringify(repro, null, 2)); el.jsonFeedback.textContent = "Minimal repro JSON copied."; } catch (err) { el.jsonFeedback.textContent = `Copy repro failed: ${err.message}`; } }); el.applyJsonBtn.addEventListener("click", async () => { try { const parsed = JSON.parse(el.jsonEditor.value); const before = state.model ? clone(state.model) : null; el.jsonFeedback.textContent = "Applying JSON..."; setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; await compileModel(parsed, { fit: true }); el.jsonFeedback.textContent = summarizeModelDelta(before, state.model); } catch (err) { const p = parseJsonPositionError(el.jsonEditor.value, err); el.jsonFeedback.textContent = `Apply failed: ${p.message}`; } }); el.newProjectBtn.addEventListener("click", async () => { setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; await compileModel(defaultProject(), { fit: true }); }); el.loadSampleBtn.addEventListener("click", loadSample); el.autoLayoutBtn.addEventListener("click", async () => { await runLayoutAction("/layout/auto"); }); el.autoTidyBtn.addEventListener("click", async () => { await runLayoutAction("/layout/tidy"); }); el.importBtn.addEventListener("click", () => { el.fileInput.click(); }); el.fileInput.addEventListener("change", async (evt) => { const file = evt.target.files?.[0]; if (!file) { return; } try { const content = await file.text(); const parsed = JSON.parse(content); setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; await compileModel(parsed, { fit: true }); } catch (err) { setStatus(`Import failed: ${err.message}`, false); } el.fileInput.value = ""; }); el.exportBtn.addEventListener("click", () => { if (!state.model) { return; } const blob = new Blob([JSON.stringify(state.model, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${(state.model.meta?.title || "schemeta").toString().replace(/\s+/g, "_").toLowerCase()}.schemeta.json`; document.body.appendChild(a); a.click(); URL.revokeObjectURL(a.href); document.body.removeChild(a); }); } (async function init() { setupEvents(); updateTransform(); const snapshots = JSON.parse(localStorage.getItem(SNAPSHOTS_KEY) ?? "[]"); if (snapshots.length) { await compileModel(snapshots[0].model, { fit: true }); } else { await loadSample(); } })();