3192 lines
99 KiB
JavaScript
3192 lines
99 KiB
JavaScript
const GRID = 20;
|
|
const SNAPSHOTS_KEY = "schemeta:snapshots:v2";
|
|
const SCHEMA_URL = "/schemeta.schema.json";
|
|
const INSPECTOR_SECTIONS_KEY = "schemeta:inspector-sections:v1";
|
|
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 LIST_ROW_HEIGHT = 36;
|
|
const LIST_OVERSCAN_ROWS = 8;
|
|
|
|
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,
|
|
symbolMigrationAckHash: 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"),
|
|
componentSection: document.getElementById("componentSection"),
|
|
symbolEditor: document.getElementById("symbolEditor"),
|
|
symbolSection: document.getElementById("symbolSection"),
|
|
pinEditor: document.getElementById("pinEditor"),
|
|
pinSection: document.getElementById("pinSection"),
|
|
netEditor: document.getElementById("netEditor"),
|
|
netSection: document.getElementById("netSection"),
|
|
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"),
|
|
duplicateComponentBtn: document.getElementById("duplicateComponentBtn"),
|
|
deleteComponentBtn: document.getElementById("deleteComponentBtn"),
|
|
isolateSelectedComponentBtn: document.getElementById("isolateSelectedComponentBtn"),
|
|
symbolMeta: document.getElementById("symbolMeta"),
|
|
symbolCategoryInput: document.getElementById("symbolCategoryInput"),
|
|
symbolWidthInput: document.getElementById("symbolWidthInput"),
|
|
symbolHeightInput: document.getElementById("symbolHeightInput"),
|
|
addSymbolPinBtn: document.getElementById("addSymbolPinBtn"),
|
|
previewSymbolBtn: document.getElementById("previewSymbolBtn"),
|
|
applySymbolBtn: document.getElementById("applySymbolBtn"),
|
|
symbolValidation: document.getElementById("symbolValidation"),
|
|
symbolMigrationPreview: document.getElementById("symbolMigrationPreview"),
|
|
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"),
|
|
isolateSelectedNetBtn: document.getElementById("isolateSelectedNetBtn"),
|
|
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 nextRefLike(baseRef) {
|
|
const base = normalizeRef(baseRef || "X1");
|
|
const m = /^([A-Za-z_]+)(\d+)?$/i.exec(base);
|
|
const prefix = m?.[1] ?? `${base}_`;
|
|
let n = Number(m?.[2] ?? 1);
|
|
let candidate = `${prefix}${n}`;
|
|
while (instanceByRef(candidate)) {
|
|
n += 1;
|
|
candidate = `${prefix}${n}`;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
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)}%`;
|
|
const svg = el.canvasInner.querySelector("svg");
|
|
if (svg) {
|
|
applyLabelDensityByZoom(svg);
|
|
resolveLabelCollisions(svg);
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
applyLabelDensityByZoom(svg);
|
|
resolveLabelCollisions(svg);
|
|
}
|
|
|
|
function boxesOverlap(a, b, pad = 2) {
|
|
return !(a.right + pad < b.left || a.left - pad > b.right || a.bottom + pad < b.top || a.top - pad > b.bottom);
|
|
}
|
|
|
|
function labelPriority(node) {
|
|
if (node.hasAttribute("data-net-label")) return 4;
|
|
if (node.hasAttribute("data-ref-label")) return 3;
|
|
if (node.hasAttribute("data-pin-label")) return 2;
|
|
if (node.hasAttribute("data-value-label")) return 1;
|
|
return 0;
|
|
}
|
|
|
|
function applyLabelDensityByZoom(svg) {
|
|
const pinLabels = svg.querySelectorAll("[data-pin-label]");
|
|
const valueLabels = svg.querySelectorAll("[data-value-label]");
|
|
const refLabels = svg.querySelectorAll("[data-ref-label]");
|
|
const dense = state.scale < 0.85;
|
|
const veryDense = state.scale < 0.65;
|
|
|
|
pinLabels.forEach((n) => {
|
|
n.style.display = dense ? "none" : "";
|
|
});
|
|
valueLabels.forEach((n) => {
|
|
n.style.display = veryDense ? "none" : "";
|
|
});
|
|
refLabels.forEach((n) => {
|
|
n.style.display = veryDense ? "none" : "";
|
|
});
|
|
}
|
|
|
|
function resolveLabelCollisions(svg) {
|
|
if (!state.showLabels) {
|
|
return;
|
|
}
|
|
|
|
const labels = [
|
|
...svg.querySelectorAll("[data-net-label], [data-pin-label], [data-ref-label], [data-value-label]")
|
|
].filter((n) => n.style.display !== "none");
|
|
|
|
for (const n of labels) {
|
|
n.style.visibility = "";
|
|
}
|
|
|
|
const netSeen = [];
|
|
for (const node of labels) {
|
|
const net = node.getAttribute("data-net-label");
|
|
if (!net) {
|
|
continue;
|
|
}
|
|
const box = node.getBoundingClientRect();
|
|
let dup = false;
|
|
for (const prev of netSeen) {
|
|
if (prev.net !== net) {
|
|
continue;
|
|
}
|
|
const dx = Math.abs((box.left + box.right) / 2 - prev.cx);
|
|
const dy = Math.abs((box.top + box.bottom) / 2 - prev.cy);
|
|
if (dx < 30 && dy < 18) {
|
|
dup = true;
|
|
break;
|
|
}
|
|
}
|
|
if (dup) {
|
|
node.style.visibility = "hidden";
|
|
continue;
|
|
}
|
|
netSeen.push({
|
|
net,
|
|
cx: (box.left + box.right) / 2,
|
|
cy: (box.top + box.bottom) / 2
|
|
});
|
|
}
|
|
|
|
const active = labels.filter((n) => n.style.display !== "none" && n.style.visibility !== "hidden");
|
|
const entries = active.map((node) => ({
|
|
node,
|
|
priority: labelPriority(node),
|
|
box: node.getBoundingClientRect()
|
|
}));
|
|
|
|
for (let i = 0; i < entries.length; i += 1) {
|
|
const a = entries[i];
|
|
if (a.node.style.visibility === "hidden") {
|
|
continue;
|
|
}
|
|
for (let j = i + 1; j < entries.length; j += 1) {
|
|
const b = entries[j];
|
|
if (b.node.style.visibility === "hidden") {
|
|
continue;
|
|
}
|
|
if (!boxesOverlap(a.box, b.box, 1)) {
|
|
continue;
|
|
}
|
|
|
|
if (a.priority === b.priority) {
|
|
b.node.style.visibility = "hidden";
|
|
} else if (a.priority > b.priority) {
|
|
b.node.style.visibility = "hidden";
|
|
} else {
|
|
a.node.style.visibility = "hidden";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderVirtualList(listEl, allItems, renderRow, rowHeight = LIST_ROW_HEIGHT) {
|
|
const items = Array.isArray(allItems) ? allItems : [];
|
|
if (!items.length) {
|
|
listEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const viewportHeight = Math.max(rowHeight * 4, listEl.clientHeight || 230);
|
|
const scrollTop = listEl.scrollTop;
|
|
const total = items.length;
|
|
const visibleRows = Math.ceil(viewportHeight / rowHeight);
|
|
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - LIST_OVERSCAN_ROWS);
|
|
const end = Math.min(total, start + visibleRows + LIST_OVERSCAN_ROWS * 2);
|
|
const topPad = start * rowHeight;
|
|
const bottomPad = (total - end) * rowHeight;
|
|
|
|
const rows = items.slice(start, end).map(renderRow).join("");
|
|
listEl.innerHTML = `<li class="listSpacer" style="height:${topPad}px"></li>${rows}<li class="listSpacer" style="height:${bottomPad}px"></li>`;
|
|
}
|
|
|
|
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));
|
|
|
|
renderVirtualList(
|
|
el.instanceList,
|
|
items,
|
|
(inst) => {
|
|
const cls = state.selectedRefs.includes(inst.ref) ? "active" : "";
|
|
return `<li class="${cls}" data-ref-item="${inst.ref}" tabindex="0" role="button" aria-label="Instance ${inst.ref}, symbol ${inst.symbol}">${inst.ref} · ${inst.symbol}</li>`;
|
|
},
|
|
LIST_ROW_HEIGHT
|
|
);
|
|
}
|
|
|
|
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));
|
|
|
|
renderVirtualList(
|
|
el.netList,
|
|
items,
|
|
(net) => {
|
|
const cls = net.name === state.selectedNet ? "active" : "";
|
|
return `<li class="${cls}" data-net-item="${net.name}" tabindex="0" role="button" aria-label="Net ${net.name}, class ${net.class}">${net.name} <small>(${net.class})</small></li>`;
|
|
},
|
|
LIST_ROW_HEIGHT
|
|
);
|
|
}
|
|
|
|
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 instances = (state.model.instances ?? []).filter((i) => i.symbol === symbolId);
|
|
const refs = new Set(instances.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const inst of instances) {
|
|
const pinUi = inst.properties?.pin_ui;
|
|
if (!pinUi || typeof pinUi !== "object" || Array.isArray(pinUi)) {
|
|
continue;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(pinUi, oldPinName) && !Object.prototype.hasOwnProperty.call(pinUi, newPinName)) {
|
|
pinUi[newPinName] = pinUi[oldPinName];
|
|
delete pinUi[oldPinName];
|
|
} else if (Object.prototype.hasOwnProperty.call(pinUi, oldPinName) && Object.prototype.hasOwnProperty.call(pinUi, newPinName)) {
|
|
delete pinUi[oldPinName];
|
|
}
|
|
}
|
|
|
|
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-move-symbol-pin="up" title="Move pin up">Up</button>
|
|
<button type="button" data-move-symbol-pin="down" title="Move pin down">Down</button>
|
|
<button type="button" data-remove-symbol-pin="${escHtml(pin.name)}">Remove</button>
|
|
</div>`;
|
|
}
|
|
|
|
function invalidateSymbolMigrationPreview(message = "") {
|
|
state.symbolMigrationAckHash = null;
|
|
if (el.symbolMigrationPreview) {
|
|
el.symbolMigrationPreview.textContent = message;
|
|
el.symbolMigrationPreview.classList.remove("migrationPreview");
|
|
}
|
|
}
|
|
|
|
function collectSymbolDraft(ref) {
|
|
const inst = instanceByRef(ref);
|
|
const sym = symbolForRef(ref);
|
|
if (!inst || !sym) {
|
|
return { ok: false, message: "No symbol selected." };
|
|
}
|
|
|
|
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) {
|
|
return { ok: false, message: "Symbol width/height must be >= 20." };
|
|
}
|
|
|
|
const rows = [...el.symbolPinsList.querySelectorAll(".symbolPinRow")];
|
|
clearSymbolRowValidation(rows);
|
|
if (!rows.length) {
|
|
return { ok: false, message: "Symbol must contain at least one pin row." };
|
|
}
|
|
|
|
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) {
|
|
row.classList.add("invalidRow");
|
|
return { ok: false, message: "Each pin row needs name, number, valid side/type, and offset >= 0." };
|
|
}
|
|
parsedPins.push({
|
|
oldName: row.getAttribute("data-old-pin") ?? name,
|
|
pin: { name, number, side, offset: Math.round(offset), type }
|
|
});
|
|
}
|
|
|
|
const nameCounts = new Map();
|
|
const numberCounts = new Map();
|
|
for (const p of parsedPins) {
|
|
nameCounts.set(p.pin.name, (nameCounts.get(p.pin.name) ?? 0) + 1);
|
|
numberCounts.set(p.pin.number, (numberCounts.get(p.pin.number) ?? 0) + 1);
|
|
}
|
|
let dupName = null;
|
|
let dupNumber = null;
|
|
for (const [name, count] of nameCounts) {
|
|
if (count > 1) {
|
|
dupName = name;
|
|
break;
|
|
}
|
|
}
|
|
for (const [num, count] of numberCounts) {
|
|
if (count > 1) {
|
|
dupNumber = num;
|
|
break;
|
|
}
|
|
}
|
|
if (dupName || dupNumber) {
|
|
for (const row of rows) {
|
|
const name = String(row.querySelector(".pinName")?.value ?? "").trim();
|
|
const num = String(row.querySelector(".pinNumber")?.value ?? "").trim();
|
|
if ((dupName && name === dupName) || (dupNumber && num === dupNumber)) {
|
|
row.classList.add("invalidRow");
|
|
}
|
|
}
|
|
if (dupName) {
|
|
return { ok: false, message: `Duplicate pin name '${dupName}' detected.` };
|
|
}
|
|
return { ok: false, message: `Duplicate pin number '${dupNumber}' detected.` };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
inst,
|
|
sym,
|
|
parsedPins,
|
|
nextCategory,
|
|
nextWidth: Math.round(nextWidth),
|
|
nextHeight: Math.round(nextHeight)
|
|
};
|
|
}
|
|
|
|
function buildSymbolMigrationPlan(symbolId, beforePins, parsedPins) {
|
|
const beforeNames = new Set(beforePins.map((p) => p.name));
|
|
const afterNames = new Set(parsedPins.map((p) => p.pin.name));
|
|
const renameMap = new Map();
|
|
for (const entry of parsedPins) {
|
|
if (entry.oldName && beforeNames.has(entry.oldName) && entry.oldName !== entry.pin.name) {
|
|
renameMap.set(entry.oldName, entry.pin.name);
|
|
}
|
|
}
|
|
|
|
const removedPins = beforePins
|
|
.map((p) => p.name)
|
|
.filter((name) => !afterNames.has(name) && !renameMap.has(name))
|
|
.sort((a, b) => a.localeCompare(b));
|
|
const renamedPins = [...renameMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
const refs = new Set((state.model.instances ?? []).filter((i) => i.symbol === symbolId).map((i) => i.ref));
|
|
|
|
let droppedNodes = 0;
|
|
const touchedNets = new Set();
|
|
for (const net of state.model.nets ?? []) {
|
|
for (const node of net.nodes ?? []) {
|
|
if (!refs.has(node.ref)) {
|
|
continue;
|
|
}
|
|
const migratedPin = renameMap.get(node.pin) ?? node.pin;
|
|
if (!afterNames.has(migratedPin)) {
|
|
droppedNodes += 1;
|
|
touchedNets.add(net.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
let removedPinUiEntries = 0;
|
|
for (const inst of state.model.instances ?? []) {
|
|
if (!refs.has(inst.ref)) {
|
|
continue;
|
|
}
|
|
const pinUi = inst.properties?.pin_ui;
|
|
if (!pinUi || typeof pinUi !== "object" || Array.isArray(pinUi)) {
|
|
continue;
|
|
}
|
|
for (const key of Object.keys(pinUi)) {
|
|
const migratedPin = renameMap.get(key) ?? key;
|
|
if (!afterNames.has(migratedPin)) {
|
|
removedPinUiEntries += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const hasDestructive = removedPins.length > 0 || droppedNodes > 0 || removedPinUiEntries > 0;
|
|
const hash = JSON.stringify({
|
|
symbolId,
|
|
renamedPins,
|
|
removedPins,
|
|
droppedNodes,
|
|
touchedNets: [...touchedNets].sort(),
|
|
removedPinUiEntries
|
|
});
|
|
|
|
return {
|
|
hash,
|
|
hasDestructive,
|
|
renamedPins,
|
|
removedPins,
|
|
droppedNodes,
|
|
touchedNets: [...touchedNets].sort(),
|
|
removedPinUiEntries
|
|
};
|
|
}
|
|
|
|
function renderSymbolMigrationPlan(plan) {
|
|
if (!el.symbolMigrationPreview) {
|
|
return;
|
|
}
|
|
const lines = [];
|
|
lines.push(`Renamed pins: ${plan.renamedPins.length}`);
|
|
lines.push(`Removed pins: ${plan.removedPins.length}`);
|
|
lines.push(`Dropped net nodes: ${plan.droppedNodes}`);
|
|
lines.push(`Removed pin-ui mappings: ${plan.removedPinUiEntries}`);
|
|
if (plan.removedPins.length) {
|
|
lines.push(`Removed: ${plan.removedPins.join(", ")}`);
|
|
}
|
|
if (plan.touchedNets.length) {
|
|
const sample = plan.touchedNets.slice(0, 8).join(", ");
|
|
const suffix = plan.touchedNets.length > 8 ? ` (+${plan.touchedNets.length - 8} more)` : "";
|
|
lines.push(`Affected nets: ${sample}${suffix}`);
|
|
}
|
|
lines.push(plan.hasDestructive ? "Destructive changes detected. Preview must be acknowledged before apply." : "No destructive migration detected.");
|
|
el.symbolMigrationPreview.innerHTML = `<div>${lines.map((line) => escHtml(line)).join("<br/>")}</div>`;
|
|
el.symbolMigrationPreview.classList.add("migrationPreview");
|
|
}
|
|
|
|
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.symbolValidation.textContent = "";
|
|
el.symbolValidation.classList.remove("symbolValidationError");
|
|
invalidateSymbolMigrationPreview("");
|
|
el.symbolPinsList.innerHTML = (sym.pins ?? []).map((pin) => symbolPinRowHtml(pin)).join("");
|
|
el.symbolEditor.classList.remove("hidden");
|
|
}
|
|
|
|
function clearSymbolRowValidation(rows) {
|
|
for (const row of rows) {
|
|
row.classList.remove("invalidRow");
|
|
}
|
|
}
|
|
|
|
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() {
|
|
el.duplicateComponentBtn.disabled = true;
|
|
el.deleteComponentBtn.disabled = true;
|
|
el.isolateSelectedComponentBtn.disabled = true;
|
|
el.isolateSelectedNetBtn.disabled = true;
|
|
|
|
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 ?? "");
|
|
el.duplicateComponentBtn.disabled = false;
|
|
el.deleteComponentBtn.disabled = false;
|
|
el.isolateSelectedComponentBtn.disabled = false;
|
|
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.isolateSelectedNetBtn.disabled = false;
|
|
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();
|
|
const svg = el.canvasInner.querySelector("svg");
|
|
if (svg) {
|
|
applyLabelDensityByZoom(svg);
|
|
resolveLabelCollisions(svg);
|
|
}
|
|
}
|
|
|
|
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.label_tie_routes ?? 0} tie-nets, ${(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 refsForSymbol(symbolId) {
|
|
return new Set((state.model?.instances ?? []).filter((i) => i.symbol === symbolId).map((i) => i.ref));
|
|
}
|
|
|
|
function symbolPinUsage(symbolId, pinName) {
|
|
const refs = refsForSymbol(symbolId);
|
|
const nets = new Set();
|
|
let nodes = 0;
|
|
for (const net of state.model?.nets ?? []) {
|
|
for (const node of net.nodes ?? []) {
|
|
if (refs.has(node.ref) && node.pin === pinName) {
|
|
nodes += 1;
|
|
nets.add(net.name);
|
|
}
|
|
}
|
|
}
|
|
return { refs: refs.size, nodes, nets: [...nets].sort() };
|
|
}
|
|
|
|
function disconnectImpact(netName, ref, pin) {
|
|
const net = netByName(netName);
|
|
if (!net) {
|
|
return { remainingNodes: 0, orphaned: false };
|
|
}
|
|
const remainingNodes = (net.nodes ?? []).filter((n) => !(n.ref === ref && n.pin === pin)).length;
|
|
return {
|
|
remainingNodes,
|
|
orphaned: remainingNodes < 2
|
|
};
|
|
}
|
|
|
|
function deleteComponentImpact(ref) {
|
|
const impacted = [];
|
|
let removedNodes = 0;
|
|
for (const net of state.model?.nets ?? []) {
|
|
const before = net.nodes?.length ?? 0;
|
|
const after = (net.nodes ?? []).filter((n) => n.ref !== ref).length;
|
|
if (before !== after) {
|
|
removedNodes += before - after;
|
|
impacted.push({ name: net.name, removed: before - after, orphaned: after < 2 });
|
|
}
|
|
}
|
|
return { removedNodes, impacted };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function saveInspectorSectionState() {
|
|
const stateObj = {
|
|
component: Boolean(el.componentSection?.open),
|
|
symbol: Boolean(el.symbolSection?.open),
|
|
pin: Boolean(el.pinSection?.open),
|
|
net: Boolean(el.netSection?.open)
|
|
};
|
|
localStorage.setItem(INSPECTOR_SECTIONS_KEY, JSON.stringify(stateObj));
|
|
}
|
|
|
|
function loadInspectorSectionState() {
|
|
try {
|
|
const raw = localStorage.getItem(INSPECTOR_SECTIONS_KEY);
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed !== "object" || !parsed) {
|
|
return;
|
|
}
|
|
if (el.componentSection) el.componentSection.open = parsed.component !== false;
|
|
if (el.symbolSection) el.symbolSection.open = parsed.symbol !== false;
|
|
if (el.pinSection) el.pinSection.open = parsed.pin !== false;
|
|
if (el.netSection) el.netSection.open = parsed.net !== false;
|
|
} catch {}
|
|
}
|
|
|
|
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, ${out.compile.layout_metrics.label_tie_routes ?? 0} tie-nets)`
|
|
);
|
|
} 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", () => {
|
|
el.instanceList.scrollTop = 0;
|
|
renderInstances();
|
|
});
|
|
el.netFilter.addEventListener("input", () => {
|
|
el.netList.scrollTop = 0;
|
|
renderNets();
|
|
});
|
|
el.instanceList.addEventListener("scroll", renderInstances, { passive: true });
|
|
el.netList.addEventListener("scroll", renderNets, { passive: true });
|
|
[el.componentSection, el.symbolSection, el.pinSection, el.netSection].forEach((section) => {
|
|
if (!section) {
|
|
return;
|
|
}
|
|
section.addEventListener("toggle", saveInspectorSectionState);
|
|
});
|
|
|
|
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.instanceList.addEventListener("keydown", (evt) => {
|
|
const item = evt.target.closest("[data-ref-item]");
|
|
if (!item) {
|
|
return;
|
|
}
|
|
if (evt.key !== "Enter" && evt.key !== " ") {
|
|
return;
|
|
}
|
|
evt.preventDefault();
|
|
item.click();
|
|
});
|
|
|
|
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.netList.addEventListener("keydown", (evt) => {
|
|
const item = evt.target.closest("[data-net-item]");
|
|
if (!item) {
|
|
return;
|
|
}
|
|
if (evt.key !== "Enter" && evt.key !== " ") {
|
|
return;
|
|
}
|
|
evt.preventDefault();
|
|
item.click();
|
|
});
|
|
|
|
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.duplicateComponentBtn.addEventListener("click", () => {
|
|
if (state.selectedRefs.length !== 1 || !state.selectedRef || !state.model) {
|
|
return;
|
|
}
|
|
const inst = instanceByRef(state.selectedRef);
|
|
if (!inst) {
|
|
return;
|
|
}
|
|
pushHistory("duplicate-component");
|
|
const nextRef = nextRefLike(inst.ref);
|
|
const next = clone(inst);
|
|
next.ref = nextRef;
|
|
next.placement = {
|
|
...next.placement,
|
|
x: toGrid(Number(next.placement.x ?? 0) + GRID * 2),
|
|
y: toGrid(Number(next.placement.y ?? 0) + GRID * 2),
|
|
locked: false
|
|
};
|
|
state.model.instances.push(next);
|
|
selectSingleRef(nextRef);
|
|
state.selectedNet = null;
|
|
state.selectedPin = null;
|
|
state.isolateNet = false;
|
|
el.jsonFeedback.textContent = `Duplicated ${inst.ref} as ${nextRef}.`;
|
|
queueCompile(true, "duplicate-component");
|
|
});
|
|
|
|
el.deleteComponentBtn.addEventListener("click", () => {
|
|
if (state.selectedRefs.length !== 1 || !state.selectedRef || !state.model) {
|
|
return;
|
|
}
|
|
const ref = state.selectedRef;
|
|
const impact = deleteComponentImpact(ref);
|
|
const orphaned = impact.impacted.filter((x) => x.orphaned).map((x) => x.name);
|
|
const summary =
|
|
`Delete ${ref}?\n` +
|
|
`- Net nodes removed: ${impact.removedNodes}\n` +
|
|
`- Nets touched: ${impact.impacted.length}\n` +
|
|
(orphaned.length ? `- Nets that will be removed (<2 nodes): ${orphaned.slice(0, 8).join(", ")}${orphaned.length > 8 ? " ..." : ""}\n` : "");
|
|
if (!window.confirm(summary)) {
|
|
return;
|
|
}
|
|
pushHistory("delete-component");
|
|
state.model.instances = state.model.instances.filter((i) => i.ref !== ref);
|
|
for (const net of state.model.nets ?? []) {
|
|
net.nodes = (net.nodes ?? []).filter((n) => n.ref !== ref);
|
|
}
|
|
state.model.nets = (state.model.nets ?? []).filter((n) => (n.nodes ?? []).length >= 2);
|
|
setSelectedRefs([]);
|
|
state.selectedNet = null;
|
|
state.selectedPin = null;
|
|
state.isolateComponent = false;
|
|
state.isolateNet = false;
|
|
el.jsonFeedback.textContent = `Deleted component ${ref}.`;
|
|
queueCompile(true, "delete-component");
|
|
});
|
|
|
|
el.isolateSelectedComponentBtn.addEventListener("click", () => {
|
|
if (!state.selectedRef) {
|
|
return;
|
|
}
|
|
state.isolateComponent = true;
|
|
state.isolateNet = false;
|
|
state.selectedNet = null;
|
|
renderAll();
|
|
});
|
|
|
|
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");
|
|
const impact = disconnectImpact(netName, state.selectedPin.ref, state.selectedPin.pin);
|
|
const message = impact.orphaned
|
|
? `Disconnect ${state.selectedPin.ref}.${state.selectedPin.pin} from ${netName}?\nThis will leave fewer than 2 nodes and remove net '${netName}'.`
|
|
: `Disconnect ${state.selectedPin.ref}.${state.selectedPin.pin} from ${netName}?`;
|
|
if (!window.confirm(message)) {
|
|
return;
|
|
}
|
|
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.isolateSelectedNetBtn.addEventListener("click", () => {
|
|
if (!state.selectedNet) {
|
|
return;
|
|
}
|
|
state.isolateNet = true;
|
|
state.isolateComponent = false;
|
|
setSelectedRefs([]);
|
|
state.selectedPin = null;
|
|
renderAll();
|
|
});
|
|
|
|
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(".");
|
|
const impact = disconnectImpact(netName, ref, pin);
|
|
const message = impact.orphaned
|
|
? `Remove ${ref}.${pin} from ${netName}?\nThis will leave fewer than 2 nodes and remove net '${netName}'.`
|
|
: `Remove ${ref}.${pin} from ${netName}?`;
|
|
if (!window.confirm(message)) {
|
|
return;
|
|
}
|
|
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));
|
|
invalidateSymbolMigrationPreview("Pin rows changed. Preview migration again before apply.");
|
|
});
|
|
|
|
el.symbolPinsList.addEventListener("click", (evt) => {
|
|
const moveBtn = evt.target.closest("[data-move-symbol-pin]");
|
|
if (moveBtn) {
|
|
const row = moveBtn.closest(".symbolPinRow");
|
|
if (!row) {
|
|
return;
|
|
}
|
|
const dir = moveBtn.getAttribute("data-move-symbol-pin");
|
|
if (dir === "up" && row.previousElementSibling) {
|
|
row.parentElement.insertBefore(row, row.previousElementSibling);
|
|
} else if (dir === "down" && row.nextElementSibling) {
|
|
row.parentElement.insertBefore(row.nextElementSibling, row);
|
|
}
|
|
invalidateSymbolMigrationPreview("Pin order changed. Preview migration again before apply.");
|
|
return;
|
|
}
|
|
|
|
const btn = evt.target.closest("[data-remove-symbol-pin]");
|
|
if (!btn) {
|
|
return;
|
|
}
|
|
const row = btn.closest(".symbolPinRow");
|
|
if (row) {
|
|
const pinName = String(row.querySelector(".pinName")?.value ?? row.getAttribute("data-old-pin") ?? "").trim();
|
|
const inst = state.selectedRef ? instanceByRef(state.selectedRef) : null;
|
|
if (inst && pinName) {
|
|
const usage = symbolPinUsage(inst.symbol, pinName);
|
|
if (usage.nodes > 0) {
|
|
const netSample = usage.nets.slice(0, 8).join(", ");
|
|
const suffix = usage.nets.length > 8 ? " ..." : "";
|
|
const prompt =
|
|
`Remove pin row '${pinName}' from symbol '${inst.symbol}' editor?\n` +
|
|
`Current usage: ${usage.nodes} net node(s) across ${usage.nets.length} net(s): ${netSample}${suffix}\n` +
|
|
"These references will be dropped when you apply symbol changes.";
|
|
if (!window.confirm(prompt)) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
row.remove();
|
|
invalidateSymbolMigrationPreview("Pin rows changed. Preview migration again before apply.");
|
|
}
|
|
});
|
|
|
|
el.symbolPinsList.addEventListener("input", (evt) => {
|
|
const row = evt.target.closest(".symbolPinRow");
|
|
if (row) {
|
|
row.classList.remove("invalidRow");
|
|
}
|
|
if (el.symbolValidation.textContent) {
|
|
el.symbolValidation.textContent = "";
|
|
el.symbolValidation.classList.remove("symbolValidationError");
|
|
}
|
|
invalidateSymbolMigrationPreview("Symbol changes pending. Preview migration before apply.");
|
|
});
|
|
|
|
el.previewSymbolBtn.addEventListener("click", () => {
|
|
if (!state.selectedRef) {
|
|
return;
|
|
}
|
|
const draft = collectSymbolDraft(state.selectedRef);
|
|
if (!draft.ok) {
|
|
el.jsonFeedback.textContent = draft.message;
|
|
el.symbolValidation.textContent = draft.message;
|
|
el.symbolValidation.classList.add("symbolValidationError");
|
|
invalidateSymbolMigrationPreview("");
|
|
return;
|
|
}
|
|
const plan = buildSymbolMigrationPlan(draft.inst.symbol, draft.sym.pins ?? [], draft.parsedPins);
|
|
renderSymbolMigrationPlan(plan);
|
|
state.symbolMigrationAckHash = plan.hash;
|
|
el.jsonFeedback.textContent = plan.hasDestructive
|
|
? "Migration preview acknowledged. Apply is now enabled for destructive symbol edits."
|
|
: "Preview complete. No destructive migration detected.";
|
|
});
|
|
|
|
el.applySymbolBtn.addEventListener("click", () => {
|
|
if (!state.selectedRef) {
|
|
return;
|
|
}
|
|
const draft = collectSymbolDraft(state.selectedRef);
|
|
if (!draft.ok) {
|
|
el.jsonFeedback.textContent = draft.message;
|
|
el.symbolValidation.textContent = draft.message;
|
|
el.symbolValidation.classList.add("symbolValidationError");
|
|
return;
|
|
}
|
|
const { inst, sym, parsedPins, nextCategory, nextWidth, nextHeight } = draft;
|
|
const plan = buildSymbolMigrationPlan(inst.symbol, sym.pins ?? [], parsedPins);
|
|
if (plan.hasDestructive && state.symbolMigrationAckHash !== plan.hash) {
|
|
renderSymbolMigrationPlan(plan);
|
|
el.jsonFeedback.textContent = "Destructive symbol edit detected. Click 'Preview Migration' before applying.";
|
|
el.symbolValidation.textContent = "Preview migration is required for pin removals or dropped net mappings.";
|
|
el.symbolValidation.classList.add("symbolValidationError");
|
|
return;
|
|
}
|
|
|
|
pushHistory("symbol-edit");
|
|
const beforePins = new Set((sym.pins ?? []).map((p) => p.name));
|
|
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: nextWidth,
|
|
height: 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 ii of state.model.instances ?? []) {
|
|
if (!refs.has(ii.ref)) {
|
|
continue;
|
|
}
|
|
const pinUi = ii.properties?.pin_ui;
|
|
if (!pinUi || typeof pinUi !== "object" || Array.isArray(pinUi)) {
|
|
continue;
|
|
}
|
|
for (const key of Object.keys(pinUi)) {
|
|
if (!allowedPins.has(key)) {
|
|
delete pinUi[key];
|
|
}
|
|
}
|
|
}
|
|
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.symbolValidation.textContent = "";
|
|
el.symbolValidation.classList.remove("symbolValidationError");
|
|
invalidateSymbolMigrationPreview("");
|
|
const removedPinCount = [...beforePins].filter((p) => !allowedPins.has(p)).length;
|
|
el.jsonFeedback.textContent = removedPinCount
|
|
? `Updated symbol ${inst.symbol}. Removed ${removedPinCount} pin mappings from nets/UI metadata.`
|
|
: `Updated symbol ${inst.symbol}.`;
|
|
queueCompile(true, "symbol-edit");
|
|
});
|
|
|
|
[el.symbolCategoryInput, el.symbolWidthInput, el.symbolHeightInput].forEach((input) => {
|
|
input?.addEventListener("input", () => {
|
|
invalidateSymbolMigrationPreview("Symbol changes pending. Preview migration before apply.");
|
|
});
|
|
});
|
|
|
|
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();
|
|
loadInspectorSectionState();
|
|
updateTransform();
|
|
|
|
const snapshots = JSON.parse(localStorage.getItem(SNAPSHOTS_KEY) ?? "[]");
|
|
if (snapshots.length) {
|
|
await compileModel(snapshots[0].model, { fit: true });
|
|
} else {
|
|
await loadSample();
|
|
}
|
|
})();
|