Improve fit/focus viewport behavior and add viewport QA coverage
@ -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
|
||||
|
||||
|
||||
145
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", () => {
|
||||
if (state.compile?.layout) {
|
||||
fitView(state.compile.layout);
|
||||
} else {
|
||||
state.scale = 1;
|
||||
state.panX = 40;
|
||||
state.panY = 40;
|
||||
state.userAdjustedView = true;
|
||||
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) => {
|
||||
|
||||
@ -68,9 +68,10 @@
|
||||
<section class="pane center">
|
||||
<div class="canvasTools">
|
||||
<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="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>
|
||||
<span id="compileStatus">Idle</span>
|
||||
</div>
|
||||
|
||||
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 177 KiB |
BIN
tests/baselines/ui/laptop-viewport.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 165 KiB |
@ -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(() => {});
|
||||
|
||||