Improve fit/focus viewport behavior and add viewport QA coverage
@ -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
|
||||||
|
|
||||||
|
|||||||
153
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 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) => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
|
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() {
|
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(() => {});
|
||||||
|
|||||||