diff --git a/frontend/app.js b/frontend/app.js index 5ad4b67..9dd02a3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -11,6 +11,7 @@ const MIN_SCALE = 0.2; const MAX_SCALE = 5; const FIT_MARGIN = 56; const FOCUS_MARGIN = 96; +const THEME_KEY = "schemeta:theme:v1"; const state = { model: null, @@ -31,7 +32,8 @@ const state = { showLabels: true, isolateNet: false, isolateComponent: false, - renderMode: "schematic_stub", + renderMode: "explicit", + theme: "dark", userAdjustedView: false, spacePan: false, schemaText: "", @@ -141,6 +143,7 @@ const el = { copyReproBtn: document.getElementById("copyReproBtn"), autoLayoutBtn: document.getElementById("autoLayoutBtn"), autoTidyBtn: document.getElementById("autoTidyBtn"), + themeToggleBtn: document.getElementById("themeToggleBtn"), shortcutsBtn: document.getElementById("shortcutsBtn"), undoBtn: document.getElementById("undoBtn"), redoBtn: document.getElementById("redoBtn"), @@ -158,7 +161,8 @@ const el = { commandModal: document.getElementById("commandModal"), closeCommandBtn: document.getElementById("closeCommandBtn"), commandInput: document.getElementById("commandInput"), - commandList: document.getElementById("commandList") + commandList: document.getElementById("commandList"), + selectedNoteHint: null }; function toGrid(v) { @@ -348,6 +352,27 @@ function setStatus(text, ok = true) { el.compileStatus.className = ok ? "status-ok" : ""; } +function selectThemePreference() { + const saved = localStorage.getItem(THEME_KEY); + if (saved === "dark" || saved === "light") { + return saved; + } + return "dark"; +} + +function applyTheme(theme, persist = true) { + const next = theme === "light" ? "light" : "dark"; + state.theme = next; + document.body.classList.toggle("theme-dark", next === "dark"); + if (el.themeToggleBtn) { + el.themeToggleBtn.textContent = next === "dark" ? "Light" : "Dark"; + el.themeToggleBtn.setAttribute("aria-label", next === "dark" ? "Switch to light mode" : "Switch to dark mode"); + } + if (persist) { + localStorage.setItem(THEME_KEY, next); + } +} + function formatCompileStatus(result) { const m = result?.layout_metrics ?? {}; return `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings ?? 0} crossings, ${m.overlap_edges ?? 0} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`; @@ -368,6 +393,8 @@ function compileOptions(extra = {}) { return { render_mode: state.renderMode, show_labels: state.showLabels, + show_annotations: false, + show_legend: false, generic_symbols: true, ...extra }; @@ -427,6 +454,58 @@ function refreshJsonEditor() { el.jsonEditor.value = JSON.stringify(state.model, null, 2); } +function selectedComponentNote() { + if (!state.model || state.selectedNet || state.selectedPin || state.selectedRefs.length !== 1) { + return null; + } + const inst = instanceByRef(state.selectedRefs[0]); + if (!inst) { + return null; + } + const raw = String(inst.properties?.note ?? inst.properties?.notes ?? "").trim(); + if (!raw) { + return null; + } + return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw; +} + +function updateSelectedNoteHint() { + if (!el.selectedNoteHint) { + return; + } + const note = selectedComponentNote(); + if (!note || !state.compile?.layout || !state.model) { + el.selectedNoteHint.classList.add("hidden"); + return; + } + const ref = state.selectedRefs[0]; + const inst = instanceByRef(ref); + const sym = inst ? state.model.symbols?.[inst.symbol] : null; + const placed = (state.compile.layout.placed ?? []).find((p) => p.ref === ref); + if (!inst || !sym || !placed) { + el.selectedNoteHint.classList.add("hidden"); + return; + } + + el.selectedNoteHint.textContent = note; + el.selectedNoteHint.classList.remove("hidden"); + el.selectedNoteHint.style.visibility = "hidden"; + + const viewportRect = el.canvasViewport.getBoundingClientRect(); + const x = (placed.x + sym.body.width / 2) * state.scale + state.panX; + const y = (placed.y - 12) * state.scale + state.panY; + el.selectedNoteHint.style.left = `${Math.round(x)}px`; + el.selectedNoteHint.style.top = `${Math.round(y)}px`; + el.selectedNoteHint.style.transform = "translate(-50%, -100%)"; + + const hintRect = el.selectedNoteHint.getBoundingClientRect(); + const minX = 14 + hintRect.width / 2; + const maxX = Math.max(minX, viewportRect.width - 14 - hintRect.width / 2); + const clampedX = Math.max(minX, Math.min(maxX, x)); + el.selectedNoteHint.style.left = `${Math.round(clampedX)}px`; + el.selectedNoteHint.style.visibility = ""; +} + function saveSnapshot() { if (!state.model) { return; @@ -451,6 +530,7 @@ function updateTransform() { applyLabelDensityByZoom(svg); resolveLabelCollisions(svg); } + updateSelectedNoteHint(); } function layoutBounds(layout, margin = FIT_MARGIN) { @@ -746,8 +826,8 @@ 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; + const dense = state.scale < 1.05; + const veryDense = state.scale < 0.8; pinLabels.forEach((n) => { n.style.display = dense ? "none" : ""; @@ -973,14 +1053,18 @@ function symbolPinRowHtml(pin) { const sideOptions = PIN_SIDES.map((s) => ``).join(""); const typeOptions = PIN_TYPES.map((t) => ``).join(""); return `
- - - - - - - - +
+ + + + + +
+
+ + + +
`; } @@ -1301,7 +1385,7 @@ function renderSelected() { 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.instNotesInput.value = String(inst.properties?.note ?? inst.properties?.notes ?? ""); el.duplicateComponentBtn.disabled = false; el.deleteComponentBtn.disabled = false; el.isolateSelectedComponentBtn.disabled = false; @@ -1693,7 +1777,11 @@ function bindSvgInteractions() { } const pt = canvasToSvgPoint(evt.clientX, evt.clientY); - const dragRefs = state.selectedRefs.length ? [...state.selectedRefs] : [ref]; + const dragRefsRaw = state.selectedRefs.length ? [...state.selectedRefs] : [ref]; + const dragRefs = dragRefsRaw.filter((r) => !instanceByRef(r)?.placement?.locked); + if (!dragRefs.length) { + return; + } const baseByRef = {}; for (const r of dragRefs) { const ii = state.model.instances.find((x) => x.ref === r); @@ -1772,6 +1860,7 @@ function bindSvgInteractions() { function renderCanvas() { if (!state.compile?.svg) { el.canvasInner.innerHTML = ""; + updateSelectedNoteHint(); return; } @@ -1784,6 +1873,7 @@ function renderCanvas() { applyLabelDensityByZoom(svg); resolveLabelCollisions(svg); } + updateSelectedNoteHint(); } function renderAll() { @@ -1868,7 +1958,7 @@ function queueCompile(keepView = true, source = "edit") { } state.compileDebounceId = setTimeout(() => { state.compileDebounceId = null; - compileModel(state.model, { keepView, source }); + compileModel(state.model, { keepView, source, preservePlacement: true }); }, 150); } @@ -2403,7 +2493,9 @@ async function runLayoutAction(path) { try { const out = await apiPost(path, { payload: state.model, - options: compileOptions() + options: compileOptions({ + respect_locks: true + }) }); state.model = applyCompileLayoutToModel(out.model, out.compile); @@ -2439,8 +2531,8 @@ async function resetToSample(opts = {}) { state.isolateNet = false; state.isolateComponent = false; state.userAdjustedView = false; - state.renderMode = "schematic_stub"; - el.renderModeSelect.value = "schematic_stub"; + state.renderMode = "explicit"; + el.renderModeSelect.value = "explicit"; state.showLabels = true; el.showLabelsInput.checked = true; el.instanceFilter.value = ""; @@ -2642,6 +2734,7 @@ function setupEvents() { properties: { ...(inst.properties ?? {}), value: el.instValueInput.value, + note: el.instNotesInput.value, notes: el.instNotesInput.value } }); @@ -3156,7 +3249,7 @@ function setupEvents() { el.renderModeSelect.addEventListener("change", async () => { state.renderMode = el.renderModeSelect.value; if (state.model) { - await compileModel(state.model, { keepView: true }); + await compileModel(state.model, { keepView: true, preservePlacement: true }); } }); @@ -3374,7 +3467,7 @@ function setupEvents() { inst.placement.rotation = ((Math.round(current / 90) * 90 + 90) % 360 + 360) % 360; inst.placement.locked = true; } - compileModel(state.model, { source: "rotate", keepView: true }); + compileModel(state.model, { source: "rotate", keepView: true, preservePlacement: true }); evt.preventDefault(); return; } @@ -3646,6 +3739,10 @@ function setupEvents() { await runLayoutAction("/layout/tidy"); }); + el.themeToggleBtn?.addEventListener("click", () => { + applyTheme(state.theme === "dark" ? "light" : "dark"); + }); + el.undoBtn.addEventListener("click", async () => { await performUndo(); }); @@ -3705,6 +3802,10 @@ function setupEvents() { } (async function init() { + el.selectedNoteHint = document.createElement("div"); + el.selectedNoteHint.className = "selectedNoteHint hidden"; + el.canvasViewport.appendChild(el.selectedNoteHint); + applyTheme(selectThemePreference()); setupEvents(); loadInspectorSectionState(); updateTransform(); diff --git a/frontend/index.html b/frontend/index.html index 3890801..9757b63 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,14 +20,15 @@ + diff --git a/frontend/styles.css b/frontend/styles.css index 872a0b0..85e776d 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -28,6 +28,33 @@ --shadow-2: 0 10px 24px rgba(16, 24, 40, 0.08); } +body.theme-dark { + --bg-0: #0b1220; + --bg-1: #0f172a; + --bg-2: #172237; + --panel: #0f1c2e; + --panel-strong: #13233a; + --canvas: #0d1728; + --ink: #e6edf7; + --ink-muted: #a7b6cd; + --ink-subtle: #7f91ad; + --line: #2a3c57; + --line-strong: #436086; + --accent: #60a5fa; + --accent-strong: #3b82f6; + --accent-soft: #162945; + --ok: #42ba86; + --warn: #f0b54f; + --err: #ef7367; + --power: #f59a4b; + --ground: #9ab0ca; + --clock: #f1835f; + --signal: #7aa4ff; + --analog: #4ec2ba; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-2: 0 10px 24px rgba(0, 0, 0, 0.36); +} + * { box-sizing: border-box; } @@ -38,6 +65,13 @@ body { min-height: 100%; } +body.theme-dark { + background: + radial-gradient(circle at 0% 0%, #2f1f17 0 18%, transparent 30%), + radial-gradient(circle at 96% 5%, #122739 0 18%, transparent 30%), + linear-gradient(180deg, var(--bg-1), var(--bg-0)); +} + body { font-family: "IBM Plex Sans", "Manrope", "Segoe UI", sans-serif; color: var(--ink); @@ -85,7 +119,7 @@ button { button:hover { border-color: var(--line-strong); - background: #f3f8ff; + background: color-mix(in oklab, var(--accent-soft) 58%, var(--panel-strong)); } button:disabled { @@ -122,7 +156,7 @@ button.activeChip { gap: 14px; padding: 10px 12px; border-bottom: 1px solid var(--line); - background: linear-gradient(180deg, #f9fcff, #f2f8ff); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 88%, #ffffff), var(--panel)); backdrop-filter: blur(2px); position: sticky; top: 0; @@ -174,7 +208,7 @@ button.activeChip { .pane { border: 1px solid var(--line); border-radius: var(--radius-md); - background: linear-gradient(180deg, var(--panel), #f6faff); + background: linear-gradient(180deg, var(--panel), color-mix(in oklab, var(--panel) 88%, #081120)); box-shadow: var(--shadow-1); } @@ -189,7 +223,7 @@ button.activeChip { .pane.center { overflow: hidden; - background: linear-gradient(180deg, #fcfeff, #f6faff); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 90%, #ffffff), var(--panel)); } .sectionHead { @@ -217,7 +251,7 @@ select { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px; - background: #fff; + background: var(--panel-strong); color: var(--ink); } @@ -241,7 +275,7 @@ textarea { overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.72); + background: color-mix(in oklab, var(--panel-strong) 84%, transparent); } .list li { @@ -252,7 +286,7 @@ textarea { } .list li:hover { - background: #f2f7ff; + background: color-mix(in oklab, var(--accent-soft) 70%, var(--panel-strong)); } .list li:last-child { @@ -290,7 +324,7 @@ textarea { .netLegend { border: 1px solid var(--line); border-radius: var(--radius-sm); - background: #fbfdff; + background: var(--panel-strong); padding: 8px; display: grid; gap: 6px; @@ -334,7 +368,7 @@ textarea { .card { border: 1px solid var(--line); border-radius: var(--radius-sm); - background: #fbfdff; + background: var(--panel-strong); padding: 8px; color: var(--ink-muted); font-size: 0.85rem; @@ -346,7 +380,7 @@ textarea { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px; - background: #f8fbff; + background: color-mix(in oklab, var(--panel-strong) 86%, var(--panel)); display: flex; flex-direction: column; gap: 8px; @@ -356,7 +390,7 @@ textarea { border: 1px solid var(--line); border-radius: var(--radius-sm); margin-top: 8px; - background: #ffffffcc; + background: color-mix(in oklab, var(--panel-strong) 88%, transparent); overflow: hidden; } @@ -370,12 +404,12 @@ textarea { } .editorSection > summary:hover { - background: #f3f8ff; + background: color-mix(in oklab, var(--accent-soft) 60%, var(--panel-strong)); } .editorSection[open] > summary { border-bottom: 1px solid var(--line); - background: #eef4ff; + background: color-mix(in oklab, var(--accent-soft) 76%, var(--panel-strong)); } .editorSection > summary::-webkit-details-marker { @@ -402,7 +436,7 @@ textarea { .miniList { border: 1px solid var(--line); border-radius: var(--radius-sm); - background: #fff; + background: var(--panel-strong); max-height: 180px; overflow: auto; } @@ -423,8 +457,27 @@ textarea { .symbolPinRow { display: grid; - align-items: center; - grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto; + align-items: start; + grid-template-columns: 1fr; + gap: 6px; +} + +.symbolPinMain { + display: grid; + grid-template-columns: minmax(72px, 1fr) minmax(58px, 0.8fr) minmax(76px, 0.9fr) minmax(66px, 0.7fr) minmax(110px, 1fr); + gap: 6px; +} + +.symbolPinActions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.symbolPinActions button { + min-width: 0; + padding: 4px 6px; + font-size: 0.75rem; } .pinCol { @@ -457,7 +510,7 @@ textarea { gap: 8px; padding: 8px; border-bottom: 1px solid var(--line); - background: linear-gradient(180deg, #f8fbff, #f2f7ff); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 90%, #ffffff), var(--panel)); } #compileStatus { @@ -477,9 +530,9 @@ textarea { overflow: hidden; cursor: grab; background: - linear-gradient(0deg, #d9e4f0 1px, transparent 1px), - linear-gradient(90deg, #d9e4f0 1px, transparent 1px), - linear-gradient(180deg, var(--canvas), #edf4fc); + linear-gradient(0deg, color-mix(in oklab, var(--line) 72%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in oklab, var(--line) 72%, transparent) 1px, transparent 1px), + linear-gradient(180deg, var(--canvas), color-mix(in oklab, var(--canvas) 82%, #1c2f49)); background-size: 20px 20px, 20px 20px, auto; } @@ -514,11 +567,26 @@ textarea { border-radius: var(--radius-sm); padding: 6px 8px; color: var(--ink); - background: rgba(255, 255, 255, 0.95); + background: color-mix(in oklab, var(--panel-strong) 92%, transparent); box-shadow: var(--shadow-2); font-size: 0.74rem; } +.selectedNoteHint { + position: absolute; + z-index: 18; + pointer-events: none; + max-width: min(460px, 56vw); + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in oklab, var(--panel-strong) 92%, transparent); + color: var(--ink); + box-shadow: var(--shadow-2); + font-size: 0.78rem; + line-height: 1.35; + padding: 8px 10px; +} + .hidden { display: none; } @@ -547,11 +615,11 @@ textarea { padding: 7px; margin-bottom: 6px; cursor: pointer; - background: #fff; + background: var(--panel-strong); } .issueRow:hover { - background: #f4f8ff; + background: color-mix(in oklab, var(--accent-soft) 66%, var(--panel-strong)); } .issueErr { @@ -604,7 +672,7 @@ textarea { height: min(88vh, 900px); border: 1px solid var(--line); border-radius: var(--radius-lg); - background: #fff; + background: var(--panel-strong); box-shadow: 0 24px 60px rgba(16, 24, 40, 0.24); padding: 12px; display: flex; @@ -658,7 +726,7 @@ textarea { padding: 8px 10px; font-size: 0.86rem; color: var(--ink-muted); - background: #f8fbff; + background: color-mix(in oklab, var(--panel-strong) 88%, var(--panel)); } kbd { @@ -667,7 +735,7 @@ kbd { border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 6px; - background: #fff; + background: var(--panel-strong); padding: 2px 6px; color: var(--ink); font-size: 0.74rem; @@ -679,7 +747,7 @@ kbd { border-radius: var(--radius-sm); overflow: auto; max-height: 46vh; - background: #fff; + background: var(--panel-strong); } .commandRow { @@ -687,7 +755,7 @@ kbd { border: none; border-bottom: 1px solid var(--line); border-radius: 0; - background: #fff; + background: var(--panel-strong); text-align: left; box-shadow: none; padding: 8px 10px; @@ -701,7 +769,7 @@ kbd { .commandRow:hover, .commandRow.active { - background: #edf4ff; + background: color-mix(in oklab, var(--accent-soft) 70%, var(--panel-strong)); } .flash { @@ -801,6 +869,10 @@ kbd { grid-template-columns: 1fr; } + .symbolPinMain { + grid-template-columns: 1fr 1fr; + } + .jsonActions { flex-direction: column; gap: 4px; diff --git a/src/render.js b/src/render.js index 5cf72f5..ca6721e 100644 --- a/src/render.js +++ b/src/render.js @@ -441,6 +441,7 @@ function renderAnnotationPanel(entries, maxWidth = 380) { export function renderSvgFromLayout(model, layout, options = {}) { const showLabels = options.show_labels !== false; const showAnnotations = options.show_annotations !== false; + const showLegend = options.show_legend !== false; const pinNets = pinNetMap(model); const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class])); const allPinPoints = []; @@ -635,7 +636,7 @@ export function renderSvgFromLayout(model, layout, options = {}) { ...(annotationPanel.height > 0 ? [{ x: annotationPanel.x - 2, y: annotationPanel.y - 2, width: annotationPanel.width + 4, height: annotationPanel.height + 4 }] : []), - { x: 6, y: legendY - 8, width: 126, height: 86 }, + ...(showLegend ? [{ x: 6, y: legendY - 8, width: 126, height: 86 }] : []), ...componentRects ]; @@ -761,7 +762,7 @@ export function renderSvgFromLayout(model, layout, options = {}) { ${labelLayer} ${busLabels} ${annotationPanel.svg} - ${renderLegend(legendY)} + ${showLegend ? renderLegend(legendY) : ""} `; }