diff --git a/frontend/app.js b/frontend/app.js
index 09093a4..7d2c704 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -40,7 +40,8 @@ const state = {
historyPast: [],
historyFuture: [],
historyLimit: 80,
- historyRestoring: false
+ historyRestoring: false,
+ symbolMigrationAckHash: null
};
const el = {
@@ -78,8 +79,10 @@ const el = {
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"),
@@ -871,10 +874,194 @@ function symbolPinRowHtml(pin) {
+
+
`;
}
+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 = `
${lines.map((line) => escHtml(line)).join("
")}
`;
+ el.symbolMigrationPreview.classList.add("migrationPreview");
+}
+
function renderSymbolEditorForRef(ref) {
const inst = instanceByRef(ref);
const sym = symbolForRef(ref);
@@ -888,6 +1075,7 @@ function renderSymbolEditorForRef(ref) {
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");
}
@@ -2318,9 +2506,26 @@ function setupEvents() {
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;
@@ -2328,6 +2533,7 @@ function setupEvents() {
const row = btn.closest(".symbolPinRow");
if (row) {
row.remove();
+ invalidateSymbolMigrationPreview("Pin rows changed. Preview migration again before apply.");
}
});
@@ -2340,76 +2546,50 @@ function setupEvents() {
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 inst = instanceByRef(state.selectedRef);
- const sym = symbolForRef(state.selectedRef);
- if (!inst || !sym) {
- return;
- }
- const nextCategory = String(el.symbolCategoryInput.value ?? "").trim() || String(sym.category ?? "generic");
- const nextWidth = Number(el.symbolWidthInput.value);
- const nextHeight = Number(el.symbolHeightInput.value);
- if (!Number.isFinite(nextWidth) || !Number.isFinite(nextHeight) || nextWidth < 20 || nextHeight < 20) {
- el.jsonFeedback.textContent = "Symbol width/height must be >= 20.";
- return;
- }
-
- const rows = [...el.symbolPinsList.querySelectorAll(".symbolPinRow")];
- clearSymbolRowValidation(rows);
- el.symbolValidation.textContent = "";
- el.symbolValidation.classList.remove("symbolValidationError");
- if (!rows.length) {
- el.jsonFeedback.textContent = "Symbol must have at least one pin.";
- el.symbolValidation.textContent = "Symbol must contain at least one pin row.";
+ 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 parsedPins = [];
- const rowErrors = [];
- 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");
- rowErrors.push("Each pin row needs name, number, valid side/type, and offset >= 0.");
- continue;
- }
- parsedPins.push({
- oldName: row.getAttribute("data-old-pin") ?? name,
- pin: { name, number, side, offset: Math.round(offset), type }
- });
- }
-
- if (rowErrors.length) {
- el.jsonFeedback.textContent = "Fix invalid symbol pin rows before applying.";
- el.symbolValidation.textContent = rowErrors[0];
+ 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;
}
- const unique = new Set(parsedPins.map((p) => p.pin.name));
- if (unique.size !== parsedPins.length) {
- el.jsonFeedback.textContent = "Duplicate pin names are not allowed.";
- el.symbolValidation.textContent = "Duplicate pin names detected. Each pin name must be unique.";
- el.symbolValidation.classList.add("symbolValidationError");
- for (const row of rows) {
- const name = String(row.querySelector(".pinName")?.value ?? "").trim();
- if (name && parsedPins.filter((p) => p.pin.name === name).length > 1) {
- row.classList.add("invalidRow");
- }
- }
- return;
- }
-
pushHistory("symbol-edit");
const beforePins = new Set((sym.pins ?? []).map((p) => p.name));
for (const entry of parsedPins) {
@@ -2420,8 +2600,8 @@ function setupEvents() {
sym.category = nextCategory;
sym.body = {
...(sym.body ?? {}),
- width: Math.round(nextWidth),
- height: Math.round(nextHeight)
+ width: nextWidth,
+ height: nextHeight
};
sym.pins = parsedPins.map((p) => p.pin);
const allowedPins = new Set(sym.pins.map((p) => p.name));
@@ -2450,6 +2630,7 @@ function setupEvents() {
}
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.`
@@ -2457,6 +2638,12 @@ function setupEvents() {
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;
diff --git a/frontend/index.html b/frontend/index.html
index b893709..28c952c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -111,9 +111,11 @@
+
+
diff --git a/frontend/styles.css b/frontend/styles.css
index cf5de50..404a347 100644
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -284,7 +284,7 @@ textarea {
.symbolPinRow {
display: grid;
- grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto;
+ grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
align-items: center;
}
@@ -303,6 +303,16 @@ textarea {
font-size: 0.76rem;
}
+.migrationPreview {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 8px;
+ background: #f8fafc;
+ margin-top: 6px;
+ margin-bottom: 6px;
+ line-height: 1.35;
+}
+
.canvasTools {
display: flex;
gap: 8px;