diff --git a/README.md b/README.md index 00cb05a..4c84b2a 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Tools: ## Workspace behavior highlights - Fit-to-view default on load/import/apply +- Focus-selection + reset-view controls for faster navigation in dense schematics - `Reset Sample` one-click deterministic baseline restore for QA/demo loops - Space + drag pan, wheel zoom, fit button - Net/component/pin selection with dimming + isolate toggles @@ -168,6 +169,7 @@ Tools: - `Ctrl/Cmd+Z` undo - `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo - `Space` rotate selected components (or pan when no selection) + - `F` focus current selection - `Alt+Enter` apply current selection editor (component/pin/net) - `Alt+C` connect selected pin to chosen net diff --git a/frontend/app.js b/frontend/app.js index a002ea0..e850b2d 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -7,6 +7,10 @@ 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 MIN_SCALE = 0.2; +const MAX_SCALE = 5; +const FIT_MARGIN = 56; +const FOCUS_MARGIN = 96; const state = { model: null, @@ -120,6 +124,7 @@ const el = { zoomOutBtn: document.getElementById("zoomOutBtn"), zoomResetBtn: document.getElementById("zoomResetBtn"), fitViewBtn: document.getElementById("fitViewBtn"), + focusSelectionBtn: document.getElementById("focusSelectionBtn"), showLabelsInput: document.getElementById("showLabelsInput"), applyJsonBtn: document.getElementById("applyJsonBtn"), showSchemaBtn: document.getElementById("showSchemaBtn"), @@ -422,11 +427,11 @@ function updateTransform() { } } -function fitView(layout) { +function layoutBounds(layout, margin = FIT_MARGIN) { const w = layout?.width ?? 0; const h = layout?.height ?? 0; if (!w || !h) { - return; + return null; } let minX = Number.POSITIVE_INFINITY; @@ -456,37 +461,104 @@ function fitView(layout) { 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) + return { + x: Math.max(0, minX - margin), + y: Math.max(0, minY - margin), + w: Math.max(1, Math.min(w, maxX - minX + margin * 2)), + h: Math.max(1, Math.min(h, maxY - minY + margin * 2)) }; +} +function centerOnBBox(bbox, fillRatio = 0.93, stickyAdjusted = false) { + if (!bbox) { + return false; + } 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))); + if (!viewport.width || !viewport.height) { + return false; + } + const sx = (viewport.width * fillRatio) / Math.max(1, bbox.w); + const sy = (viewport.height * fillRatio) / Math.max(1, bbox.h); + state.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, 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; + state.userAdjustedView = stickyAdjusted; updateTransform(); + return true; +} + +function fitView(layout) { + const bbox = layoutBounds(layout, FIT_MARGIN); + if (!bbox) { + return; + } + centerOnBBox(bbox, 0.93, false); +} + +function refsBBox(refs, margin = FOCUS_MARGIN) { + if (!refs?.size || !state.compile?.layout || !state.model) { + return null; + } + const placed = new Map((state.compile.layout.placed ?? []).map((p) => [p.ref, p])); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const ref of refs) { + const inst = instanceByRef(ref); + const p = placed.get(ref); + const sym = inst ? state.model.symbols?.[inst.symbol] : null; + if (!p || !sym?.body) { + 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)) { + return null; + } + const w = state.compile.layout.width ?? maxX; + const h = state.compile.layout.height ?? maxY; + return { + x: Math.max(0, minX - margin), + y: Math.max(0, minY - margin), + w: Math.max(1, Math.min(w, maxX - minX + margin * 2)), + h: Math.max(1, Math.min(h, maxY - minY + margin * 2)) + }; +} + +function selectedFocusBBox() { + if (!state.model || !state.compile?.layout) { + return null; + } + if (state.selectedPin) { + return refsBBox(new Set([state.selectedPin.ref]), FOCUS_MARGIN); + } + if (state.selectedRefs.length) { + return refsBBox(new Set(state.selectedRefs), FOCUS_MARGIN); + } + if (state.selectedNet) { + const refs = refsConnectedToNet(state.selectedNet); + return refsBBox(refs, FOCUS_MARGIN); + } + return layoutBounds(state.compile.layout, FIT_MARGIN); +} + +function focusSelection() { + const bbox = selectedFocusBBox(); + if (!bbox) { + return false; + } + return centerOnBBox(bbox, 0.88, true); } 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(); + centerOnBBox(bbox, 0.8, true); } function canvasToSvgPoint(clientX, clientY) { @@ -2760,23 +2832,27 @@ function setupEvents() { }); el.zoomInBtn.addEventListener("click", () => { - state.scale = Math.min(4, state.scale + 0.1); + state.scale = Math.min(MAX_SCALE, state.scale + 0.1); state.userAdjustedView = true; updateTransform(); }); el.zoomOutBtn.addEventListener("click", () => { - state.scale = Math.max(0.2, state.scale - 0.1); + state.scale = Math.max(MIN_SCALE, 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(); + if (state.compile?.layout) { + fitView(state.compile.layout); + } else { + state.scale = 1; + state.panX = 40; + state.panY = 40; + state.userAdjustedView = false; + updateTransform(); + } }); el.fitViewBtn.addEventListener("click", () => { @@ -2785,6 +2861,10 @@ function setupEvents() { } }); + el.focusSelectionBtn.addEventListener("click", () => { + focusSelection(); + }); + el.showLabelsInput.addEventListener("change", () => { state.showLabels = el.showLabelsInput.checked; setLabelLayerVisibility(); @@ -2812,7 +2892,7 @@ function setupEvents() { (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))); + state.scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + (evt.deltaY < 0 ? 0.08 : -0.08))); const rect = el.canvasViewport.getBoundingClientRect(); const px = evt.clientX - rect.left; @@ -3042,6 +3122,21 @@ function setupEvents() { el.connectPinBtn.click(); return; } + + if (!mod && !evt.altKey && !evt.shiftKey && evt.key.toLowerCase() === "f") { + if (isTypingContext(evt.target)) { + return; + } + evt.preventDefault(); + focusSelection(); + } + }); + + window.addEventListener("resize", () => { + if (!state.compile?.layout || state.userAdjustedView) { + return; + } + fitView(state.compile.layout); }); window.addEventListener("keyup", (evt) => { diff --git a/frontend/index.html b/frontend/index.html index 9a89007..f3526fe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -68,9 +68,10 @@
- + + Idle
diff --git a/tests/baselines/ui/dense-analog.png b/tests/baselines/ui/dense-analog.png index bef7b68..91bfc6b 100644 Binary files a/tests/baselines/ui/dense-analog.png and b/tests/baselines/ui/dense-analog.png differ diff --git a/tests/baselines/ui/explicit-mode-auto-tidy.png b/tests/baselines/ui/explicit-mode-auto-tidy.png index d906f37..ef346db 100644 Binary files a/tests/baselines/ui/explicit-mode-auto-tidy.png and b/tests/baselines/ui/explicit-mode-auto-tidy.png differ diff --git a/tests/baselines/ui/initial.png b/tests/baselines/ui/initial.png index bddb8e8..1405139 100644 Binary files a/tests/baselines/ui/initial.png and b/tests/baselines/ui/initial.png differ diff --git a/tests/baselines/ui/laptop-viewport.png b/tests/baselines/ui/laptop-viewport.png new file mode 100644 index 0000000..2c50715 Binary files /dev/null and b/tests/baselines/ui/laptop-viewport.png differ diff --git a/tests/baselines/ui/post-migration-apply.png b/tests/baselines/ui/post-migration-apply.png index b1afd6c..9d64503 100644 Binary files a/tests/baselines/ui/post-migration-apply.png and b/tests/baselines/ui/post-migration-apply.png differ diff --git a/tests/baselines/ui/selected-u2.png b/tests/baselines/ui/selected-u2.png index 947ef0b..7fae770 100644 Binary files a/tests/baselines/ui/selected-u2.png and b/tests/baselines/ui/selected-u2.png differ diff --git a/tests/ui-regression-runner.js b/tests/ui-regression-runner.js index 649412d..e97cbcd 100644 --- a/tests/ui-regression-runner.js +++ b/tests/ui-regression-runner.js @@ -127,6 +127,11 @@ function parseStatusMetrics(statusText) { }; } +function parseZoomPercent(text) { + const m = /(\d+)%/.exec(String(text ?? "")); + return m ? Number(m[1]) : NaN; +} + async function run() { await ensureDirs(); const port = await getFreePort(); @@ -143,6 +148,12 @@ async function run() { await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click(); await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/); + const preFocusZoom = parseZoomPercent(await page.locator("#zoomResetBtn").textContent()); + await page.getByRole("button", { name: "Focus current selection" }).click(); + const postFocusZoom = parseZoomPercent(await page.locator("#zoomResetBtn").textContent()); + assert.ok(Number.isFinite(preFocusZoom) && Number.isFinite(postFocusZoom), "zoom label should remain parseable"); + assert.ok(postFocusZoom >= preFocusZoom, `focus should not zoom out selected view (${preFocusZoom}% -> ${postFocusZoom}%)`); + await page.getByRole("button", { name: "Reset view" }).click(); await compareScene(page, "selected-u2"); await page.locator("#canvasViewport").click({ position: { x: 40, y: 40 } }); @@ -200,6 +211,12 @@ async function run() { assert.ok(metrics.crossings <= 2, `dense analog crossings too high: ${metrics.crossings}`); assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`); await compareScene(page, "dense-analog"); + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.getByRole("button", { name: "Fit schematic to viewport" }).click(); + await expectText(page, "#compileStatus", /Compiled/); + assert.ok(await page.locator("#applyJsonBtn").isVisible(), "Apply JSON button should remain visible at laptop viewport"); + await compareScene(page, "laptop-viewport"); } finally { await page.close().catch(() => {}); await browser.close().catch(() => {});