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();
}
})();