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;