Improve fit/focus viewport behavior and add viewport QA coverage

This commit is contained in:
Rbanh 2026-02-18 22:01:43 -05:00
parent 2ff3856941
commit 570e89cf4e
10 changed files with 145 additions and 30 deletions

View File

@ -158,6 +158,7 @@ Tools:
## Workspace behavior highlights ## Workspace behavior highlights
- Fit-to-view default on load/import/apply - 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 - `Reset Sample` one-click deterministic baseline restore for QA/demo loops
- Space + drag pan, wheel zoom, fit button - Space + drag pan, wheel zoom, fit button
- Net/component/pin selection with dimming + isolate toggles - Net/component/pin selection with dimming + isolate toggles
@ -168,6 +169,7 @@ Tools:
- `Ctrl/Cmd+Z` undo - `Ctrl/Cmd+Z` undo
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo - `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
- `Space` rotate selected components (or pan when no selection) - `Space` rotate selected components (or pan when no selection)
- `F` focus current selection
- `Alt+Enter` apply current selection editor (component/pin/net) - `Alt+Enter` apply current selection editor (component/pin/net)
- `Alt+C` connect selected pin to chosen net - `Alt+C` connect selected pin to chosen net

View File

@ -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 PIN_TYPES = ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"];
const LIST_ROW_HEIGHT = 36; const LIST_ROW_HEIGHT = 36;
const LIST_OVERSCAN_ROWS = 8; const LIST_OVERSCAN_ROWS = 8;
const MIN_SCALE = 0.2;
const MAX_SCALE = 5;
const FIT_MARGIN = 56;
const FOCUS_MARGIN = 96;
const state = { const state = {
model: null, model: null,
@ -120,6 +124,7 @@ const el = {
zoomOutBtn: document.getElementById("zoomOutBtn"), zoomOutBtn: document.getElementById("zoomOutBtn"),
zoomResetBtn: document.getElementById("zoomResetBtn"), zoomResetBtn: document.getElementById("zoomResetBtn"),
fitViewBtn: document.getElementById("fitViewBtn"), fitViewBtn: document.getElementById("fitViewBtn"),
focusSelectionBtn: document.getElementById("focusSelectionBtn"),
showLabelsInput: document.getElementById("showLabelsInput"), showLabelsInput: document.getElementById("showLabelsInput"),
applyJsonBtn: document.getElementById("applyJsonBtn"), applyJsonBtn: document.getElementById("applyJsonBtn"),
showSchemaBtn: document.getElementById("showSchemaBtn"), 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 w = layout?.width ?? 0;
const h = layout?.height ?? 0; const h = layout?.height ?? 0;
if (!w || !h) { if (!w || !h) {
return; return null;
} }
let minX = Number.POSITIVE_INFINITY; let minX = Number.POSITIVE_INFINITY;
@ -456,37 +461,104 @@ function fitView(layout) {
maxY = h; maxY = h;
} }
const pad = 80; return {
const bbox = { x: Math.max(0, minX - margin),
x: Math.max(0, minX - pad), y: Math.max(0, minY - margin),
y: Math.max(0, minY - pad), w: Math.max(1, Math.min(w, maxX - minX + margin * 2)),
w: Math.min(w, maxX - minX + pad * 2), h: Math.max(1, Math.min(h, maxY - minY + margin * 2))
h: Math.min(h, maxY - minY + pad * 2)
}; };
}
function centerOnBBox(bbox, fillRatio = 0.93, stickyAdjusted = false) {
if (!bbox) {
return false;
}
const viewport = el.canvasViewport.getBoundingClientRect(); const viewport = el.canvasViewport.getBoundingClientRect();
const sx = (viewport.width * 0.98) / Math.max(1, bbox.w); if (!viewport.width || !viewport.height) {
const sy = (viewport.height * 0.98) / Math.max(1, bbox.h); return false;
state.scale = Math.max(0.2, Math.min(4, Math.min(sx, sy))); }
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.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale; state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
state.userAdjustedView = false; state.userAdjustedView = stickyAdjusted;
updateTransform(); 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) { function zoomToBBox(bbox) {
if (!bbox) { if (!bbox) {
return; return;
} }
centerOnBBox(bbox, 0.8, true);
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) { function canvasToSvgPoint(clientX, clientY) {
@ -2760,23 +2832,27 @@ function setupEvents() {
}); });
el.zoomInBtn.addEventListener("click", () => { 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; state.userAdjustedView = true;
updateTransform(); updateTransform();
}); });
el.zoomOutBtn.addEventListener("click", () => { 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; state.userAdjustedView = true;
updateTransform(); updateTransform();
}); });
el.zoomResetBtn.addEventListener("click", () => { el.zoomResetBtn.addEventListener("click", () => {
state.scale = 1; if (state.compile?.layout) {
state.panX = 40; fitView(state.compile.layout);
state.panY = 40; } else {
state.userAdjustedView = true; state.scale = 1;
updateTransform(); state.panX = 40;
state.panY = 40;
state.userAdjustedView = false;
updateTransform();
}
}); });
el.fitViewBtn.addEventListener("click", () => { el.fitViewBtn.addEventListener("click", () => {
@ -2785,6 +2861,10 @@ function setupEvents() {
} }
}); });
el.focusSelectionBtn.addEventListener("click", () => {
focusSelection();
});
el.showLabelsInput.addEventListener("change", () => { el.showLabelsInput.addEventListener("change", () => {
state.showLabels = el.showLabelsInput.checked; state.showLabels = el.showLabelsInput.checked;
setLabelLayerVisibility(); setLabelLayerVisibility();
@ -2812,7 +2892,7 @@ function setupEvents() {
(evt) => { (evt) => {
evt.preventDefault(); evt.preventDefault();
const oldScale = state.scale; 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 rect = el.canvasViewport.getBoundingClientRect();
const px = evt.clientX - rect.left; const px = evt.clientX - rect.left;
@ -3042,6 +3122,21 @@ function setupEvents() {
el.connectPinBtn.click(); el.connectPinBtn.click();
return; 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) => { window.addEventListener("keyup", (evt) => {

View File

@ -68,9 +68,10 @@
<section class="pane center"> <section class="pane center">
<div class="canvasTools"> <div class="canvasTools">
<button id="zoomOutBtn" aria-label="Zoom out">-</button> <button id="zoomOutBtn" aria-label="Zoom out">-</button>
<button id="zoomResetBtn" aria-label="Reset zoom">100%</button> <button id="zoomResetBtn" aria-label="Reset view">Reset</button>
<button id="zoomInBtn" aria-label="Zoom in">+</button> <button id="zoomInBtn" aria-label="Zoom in">+</button>
<button id="fitViewBtn" aria-label="Fit schematic to viewport">Fit</button> <button id="fitViewBtn" aria-label="Fit schematic to viewport">Fit</button>
<button id="focusSelectionBtn" aria-label="Focus current selection">Focus</button>
<label class="inlineCheck"><input id="showLabelsInput" type="checkbox" checked /> Labels</label> <label class="inlineCheck"><input id="showLabelsInput" type="checkbox" checked /> Labels</label>
<span id="compileStatus">Idle</span> <span id="compileStatus">Idle</span>
</div> </div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 165 KiB

View File

@ -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() { async function run() {
await ensureDirs(); await ensureDirs();
const port = await getFreePort(); const port = await getFreePort();
@ -143,6 +148,12 @@ async function run() {
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click(); await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/); 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 compareScene(page, "selected-u2");
await page.locator("#canvasViewport").click({ position: { x: 40, y: 40 } }); 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.crossings <= 2, `dense analog crossings too high: ${metrics.crossings}`);
assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`); assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`);
await compareScene(page, "dense-analog"); 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 { } finally {
await page.close().catch(() => {}); await page.close().catch(() => {});
await browser.close().catch(() => {}); await browser.close().catch(() => {});