2532 lines
77 KiB
JavaScript
2532 lines
77 KiB
JavaScript
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,
|
|
historyPast: [],
|
|
historyFuture: [],
|
|
historyLimit: 80,
|
|
historyRestoring: false
|
|
};
|
|
|
|
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"),
|
|
undoBtn: document.getElementById("undoBtn"),
|
|
redoBtn: document.getElementById("redoBtn"),
|
|
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 captureHistorySnapshot() {
|
|
if (!state.model) {
|
|
return null;
|
|
}
|
|
return {
|
|
model: clone(state.model),
|
|
selectedRefs: [...state.selectedRefs],
|
|
selectedNet: state.selectedNet,
|
|
selectedPin: state.selectedPin ? { ...state.selectedPin, nets: [...(state.selectedPin.nets ?? [])] } : null,
|
|
isolateNet: Boolean(state.isolateNet),
|
|
isolateComponent: Boolean(state.isolateComponent)
|
|
};
|
|
}
|
|
|
|
function restoreHistorySnapshot(snapshot) {
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
state.model = clone(snapshot.model);
|
|
setSelectedRefs(snapshot.selectedRefs ?? []);
|
|
state.selectedNet = snapshot.selectedNet ?? null;
|
|
state.selectedPin = snapshot.selectedPin ? { ...snapshot.selectedPin } : null;
|
|
state.isolateNet = Boolean(snapshot.isolateNet);
|
|
state.isolateComponent = Boolean(snapshot.isolateComponent);
|
|
}
|
|
|
|
function pushHistory(reason = "edit") {
|
|
const snap = captureHistorySnapshot();
|
|
if (!snap) {
|
|
return;
|
|
}
|
|
state.historyPast.push({ reason, snapshot: snap });
|
|
if (state.historyPast.length > state.historyLimit) {
|
|
state.historyPast.splice(0, state.historyPast.length - state.historyLimit);
|
|
}
|
|
state.historyFuture = [];
|
|
}
|
|
|
|
async function performUndo() {
|
|
if (!state.historyPast.length || !state.model) {
|
|
return;
|
|
}
|
|
const current = captureHistorySnapshot();
|
|
const prev = state.historyPast.pop();
|
|
if (!prev?.snapshot || !current) {
|
|
return;
|
|
}
|
|
state.historyFuture.push({ reason: prev.reason, snapshot: current });
|
|
state.historyRestoring = true;
|
|
restoreHistorySnapshot(prev.snapshot);
|
|
await compileModel(state.model, { keepView: true, source: "undo" });
|
|
state.historyRestoring = false;
|
|
}
|
|
|
|
async function performRedo() {
|
|
if (!state.historyFuture.length || !state.model) {
|
|
return;
|
|
}
|
|
const current = captureHistorySnapshot();
|
|
const next = state.historyFuture.pop();
|
|
if (!next?.snapshot || !current) {
|
|
return;
|
|
}
|
|
state.historyPast.push({ reason: next.reason, snapshot: current });
|
|
state.historyRestoring = true;
|
|
restoreHistorySnapshot(next.snapshot);
|
|
await compileModel(state.model, { keepView: true, source: "redo" });
|
|
state.historyRestoring = false;
|
|
}
|
|
|
|
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 `<li class="${cls}" data-ref-item="${inst.ref}">${inst.ref} · ${inst.symbol}</li>`;
|
|
})
|
|
.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 `<li class="${cls}" data-net-item="${net.name}">${net.name} <small>(${net.class})</small></li>`;
|
|
})
|
|
.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) => `<option value="${s}" ${pin.side === s ? "selected" : ""}>${s}</option>`).join("");
|
|
const typeOptions = PIN_TYPES.map((t) => `<option value="${t}" ${pin.type === t ? "selected" : ""}>${t}</option>`).join("");
|
|
return `<div class="miniRow symbolPinRow" data-old-pin="${escHtml(pin.name)}">
|
|
<input class="pinCol pinName" type="text" value="${escHtml(pin.name)}" placeholder="name" />
|
|
<input class="pinCol pinNumber" type="text" value="${escHtml(pin.number)}" placeholder="number" />
|
|
<select class="pinCol pinSide">${sideOptions}</select>
|
|
<input class="pinCol pinOffset" type="number" min="0" step="1" value="${Number(pin.offset ?? 0)}" />
|
|
<select class="pinCol pinType">${typeOptions}</select>
|
|
<button type="button" data-remove-symbol-pin="${escHtml(pin.name)}">Remove</button>
|
|
</div>`;
|
|
}
|
|
|
|
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) => `<option value="${n.name}">${n.name} (${n.class})</option>`)
|
|
.join("");
|
|
el.newNetNameInput.placeholder = nextAutoNetName();
|
|
el.newNetClassInput.value = inferClassForPin(ref, pin);
|
|
el.pinConnections.innerHTML = nets.length
|
|
? nets
|
|
.map(
|
|
(name) =>
|
|
`<div class="miniRow"><span>${name}</span><button data-disconnect-net="${name}" type="button">Disconnect</button></div>`
|
|
)
|
|
.join("")
|
|
: `<div class="miniRow"><span>No net connections yet.</span></div>`;
|
|
|
|
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) =>
|
|
`<div class="miniRow"><span>${node.ref}.${node.pin}</span><button data-remove-node="${node.ref}.${node.pin}" type="button">Remove</button></div>`
|
|
)
|
|
.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) =>
|
|
`<div class="issueRow issueErr" data-issue-id="${issue.id}"><div class="issueTitle">[E] ${issue.message}</div><div class="issueMeta">${issue.code} · ${issue.path ?? "-"}</div><div class="issueMeta">${issue.suggestion ?? ""}</div></div>`
|
|
),
|
|
...warnings.map(
|
|
(issue) =>
|
|
`<div class="issueRow issueWarn" data-issue-id="${issue.id}"><div class="issueTitle">[W] ${issue.message}</div><div class="issueMeta">${issue.code} · ${issue.path ?? "-"}</div><div class="issueMeta">${issue.suggestion ?? ""}</div></div>`
|
|
)
|
|
];
|
|
|
|
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;
|
|
state.isolateComponent = false;
|
|
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;
|
|
state.isolateNet = false;
|
|
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: []
|
|
};
|
|
state.isolateNet = false;
|
|
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 = `<strong>${ref}.${pin}</strong><br/>${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) => {
|
|
if (
|
|
evt.target.closest("[data-pin-ref]") ||
|
|
evt.target.closest("[data-net]") ||
|
|
evt.target.closest("[data-net-label]") ||
|
|
evt.target.closest("[data-net-junction]") ||
|
|
evt.target.closest("[data-net-tie]")
|
|
) {
|
|
return;
|
|
}
|
|
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;
|
|
state.isolateNet = false;
|
|
renderAll();
|
|
return;
|
|
}
|
|
|
|
if (!state.selectedRefs.includes(ref)) {
|
|
selectSingleRef(ref);
|
|
}
|
|
state.selectedNet = null;
|
|
state.selectedPin = null;
|
|
state.isolateNet = false;
|
|
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;
|
|
state.isolateComponent = false;
|
|
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;
|
|
state.isolateNet = false;
|
|
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);
|
|
el.undoBtn.disabled = state.historyPast.length === 0;
|
|
el.redoBtn.disabled = state.historyFuture.length === 0;
|
|
}
|
|
|
|
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, ${m.total_bends ?? 0} bends, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`
|
|
);
|
|
} 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;
|
|
}
|
|
|
|
pushHistory(path.includes("auto") ? "auto-layout" : "auto-tidy");
|
|
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, ${out.compile.layout_metrics.overlap_edges} overlaps, ${out.compile.layout_metrics.total_bends ?? 0} bends)`
|
|
);
|
|
} 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();
|
|
if (state.model) {
|
|
pushHistory("load-sample");
|
|
}
|
|
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;
|
|
state.isolateNet = false;
|
|
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;
|
|
state.isolateComponent = false;
|
|
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;
|
|
}
|
|
|
|
pushHistory("component-edit");
|
|
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;
|
|
}
|
|
pushHistory("rotate");
|
|
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;
|
|
pushHistory("pin-ui");
|
|
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;
|
|
}
|
|
|
|
pushHistory("pin-props");
|
|
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;
|
|
}
|
|
pushHistory("connect-pin");
|
|
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;
|
|
pushHistory("create-net");
|
|
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");
|
|
pushHistory("disconnect-pin");
|
|
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;
|
|
}
|
|
pushHistory("net-edit");
|
|
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;
|
|
}
|
|
pushHistory("net-node-add");
|
|
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(".");
|
|
pushHistory("net-node-remove");
|
|
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;
|
|
}
|
|
|
|
pushHistory("symbol-edit");
|
|
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) {
|
|
pushHistory("drag-move");
|
|
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) => {
|
|
const mod = evt.ctrlKey || evt.metaKey;
|
|
if (mod && evt.key.toLowerCase() === "z") {
|
|
evt.preventDefault();
|
|
if (evt.shiftKey) {
|
|
void performRedo();
|
|
} else {
|
|
void performUndo();
|
|
}
|
|
return;
|
|
}
|
|
if (mod && evt.key.toLowerCase() === "y") {
|
|
evt.preventDefault();
|
|
void performRedo();
|
|
return;
|
|
}
|
|
|
|
if (evt.code === "Space") {
|
|
if (isTypingContext(evt.target)) {
|
|
return;
|
|
}
|
|
|
|
if (state.selectedRefs.length && state.model && !evt.repeat) {
|
|
pushHistory("rotate-hotkey");
|
|
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") {
|
|
if (!el.schemaModal.classList.contains("hidden")) {
|
|
closeSchemaModal();
|
|
return;
|
|
}
|
|
const hadSelection = Boolean(state.selectedRefs.length || state.selectedNet || state.selectedPin);
|
|
const hadIsolation = Boolean(state.isolateNet || state.isolateComponent);
|
|
if (hadSelection || hadIsolation) {
|
|
setSelectedRefs([]);
|
|
state.selectedNet = null;
|
|
state.selectedPin = null;
|
|
state.isolateNet = false;
|
|
state.isolateComponent = false;
|
|
renderAll();
|
|
}
|
|
}
|
|
});
|
|
|
|
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;
|
|
if (state.model) {
|
|
pushHistory("apply-json");
|
|
}
|
|
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 () => {
|
|
if (state.model) {
|
|
pushHistory("new-project");
|
|
}
|
|
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.undoBtn.addEventListener("click", async () => {
|
|
await performUndo();
|
|
});
|
|
|
|
el.redoBtn.addEventListener("click", async () => {
|
|
await performRedo();
|
|
});
|
|
|
|
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);
|
|
if (state.model) {
|
|
pushHistory("import-json");
|
|
}
|
|
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();
|
|
}
|
|
})();
|