Viewer: stay sharp while zooming
Deploy web Studio / deploy (push) Successful in 1m5s

Pan/zoom gestures move the drawing as a CSS-transformed layer, a picture
at the scale last drawn. A long touchpad pinch showed an ever more
stretched, blurry view (3.6x at the end of a 1.2 s pinch), and every zoom
ended with a blur-then-snap 180 ms after its last step.

Now the view is drawn anew during the gesture once the picture is 30% off
in scale (at most every 120 ms; draw() keeps the gesture state), and at its
true scale 90 ms after the last step. Measured in Brave at 1.5x pixel
density: sharp ~130 ms instead of ~240 ms after a pinch, crisp on the way.
Cost at 6x CPU slowdown: 11.4 -> 14.6 ms per wheel step in the Studio,
9.1 -> 10.5 on a page, still no frame over 50 ms.

check_web: a long pinch is drawn anew on the way and sharp within 150 ms.
This commit is contained in:
grabowski committed 2026-10-25 16:53:10 +07:00
1 parent cddf834e56
commit ccfd619a99
3 files changed
+50 -9

No files matched your search

+7
View File
@@ -101,6 +101,13 @@ the tags only); hover 22 → 15 ms per move (tooltip placed once per frame on it
readout ten times a second); rotate 226 → 154 ms, edit 409 → 305 ms until painted (WASM built with readout ten times a second); rotate 226 → 154 ms, edit 409 → 305 ms until painted (WASM built with
opt-level 3: engine render 11.7 → 9.4 ms at full speed). opt-level 3: engine render 11.7 → 9.4 ms at full speed).
Sharp zooming: the moved layer is a picture at the scale last drawn, so a long touchpad pinch showed
an ever more stretched, blurry view (3.6x at the end of a 1.2 s pinch) and every zoom ended with a
blur-then-snap 180 ms after its last step. Now the view is drawn anew during the gesture once the
picture is 30% off in scale (at most every 120 ms), and at its true scale 90 ms after the last step:
sharp about 130 instead of 240 ms after a pinch, at a cost of 11.4 → 14.6 ms per wheel step at 6x
slowdown (still no frame over 50 ms).
Production lines: Producer (a press making cartons, crates, drums, sacks or parts at a set rate Production lines: Producer (a press making cartons, crates, drums, sacks or parts at a set rate
per minute, its ram going down once per item) and Conveyor (drawn the way it runs, belt width, per minute, its ram going down once per item) and Conveyor (drawn the way it runs, belt width,
height, rise, speed; corners join without overlap). A conveyor starting at a producer's output, or height, rise, speed; corners join without overlap). A conveyor starting at a producer's output, or
+22
View File
@@ -359,6 +359,28 @@ try:
check(herd == "1 bull, 1 cow, 1 calf" and abs(rot - 45) < 1e-6, f"elephants: {herd}; the lane bends at {rot} degrees") check(herd == "1 bull, 1 cow, 1 calf" and abs(rot - 45) < 1e-6, f"elephants: {herd}; the lane bends at {rot} degrees")
shot(pg, "web-country.jpg") shot(pg, "web-country.jpg")
# a long pinch (Ctrl + small wheel steps) stays sharp: the view is drawn anew during the
# gesture whenever the stretched picture is 30% off, and at its true scale soon after it ends
pg.evaluate("""() => { const svg = document.querySelector('.iso-stage .iso-svg'); window.__vbs = [];
new MutationObserver(() => __vbs.push(performance.now())).observe(svg, {attributes: true, attributeFilter: ['viewBox']}); }""")
sb = pg.locator(".iso-stage").bounding_box()
pg.mouse.move(sb["x"] + sb["width"] * 0.6, sb["y"] + sb["height"] * 0.5)
pg.keyboard.down("Control")
t0 = pg.evaluate("performance.now()")
for i in range(40):
pg.mouse.wheel(0, -8)
pg.wait_for_timeout(12)
t1 = pg.evaluate("performance.now()")
pg.keyboard.up("Control")
pg.wait_for_timeout(400)
draws = pg.evaluate("__vbs")
during = [t for t in draws if t0 <= t <= t1]
after = [t - t1 for t in draws if t > t1]
tf = pg.evaluate("document.querySelector('.iso-stage .iso-svg').style.transform")
check(len(during) >= 2 and after and after[0] < 150 and tf == "",
f"a long pinch is drawn anew {len(during)} times on the way and sharp {round(after[0]) if after else None} ms after it ends")
pg.evaluate("__studio.view.fit()")
# ---------------------------------------------------------------- a system asking for less motion # ---------------------------------------------------------------- a system asking for less motion
# (Windows: Animation effects off): the view starts still, but the button stays and turns it on # (Windows: Animation effects off): the view starts still, but the button stays and turns it on
rctx = b.new_context(viewport={"width": 1200, "height": 800}, reduced_motion="reduce") rctx = b.new_context(viewport={"width": 1200, "height": 800}, reduced_motion="reduce")
+21 -9
View File
@@ -250,30 +250,42 @@
} }
// Pan and zoom gestures move the drawing as one compositor layer (a CSS transform from the // Pan and zoom gestures move the drawing as one compositor layer (a CSS transform from the
// viewBox last drawn, vbC, to the view asked for, vb): no repaint and no style work per step. // viewBox last drawn, vbC, to the view asked for, vb): no repaint and no style work per step.
// apply() commits vb as the viewBox (one repaint) when the gesture ends, and for every other change. // That layer is a picture at the scale last drawn, so zooming in stretches it (blurry) and
var vbC = null, live = false, commitT = 0, lastK = null; // zooming out shrinks it (blank edges): once it is 30% off, draw() paints the view anew in the
// middle of the gesture, at most every 120 ms. apply() ends the gesture 90 ms after its last
// step and commits every other change. A draw costs one repaint (~10 ms for 2000 nodes).
var vbC = null, live = false, commitT = 0, lastK = null, drawnAt = 0;
var now = window.performance ? function () { return performance.now(); } : Date.now;
function applyLive() { function applyLive() {
if (!vb) return; if (!vb) return;
if (!vbC) { apply(); return; } if (!vbC) { apply(); return; }
if (!live) wrap.classList.add("iso-gesture"); if (!live) wrap.classList.add("iso-gesture");
live = true; live = true;
clearTimeout(commitT); commitT = setTimeout(apply, 90);
var s = vbC.w / vb.w;
if ((s > 1.3 || s < 0.7) && now() - drawnAt > 120) { draw(); return; }
var s1 = size()[0] / vb.w, fitNow = vb.w >= fitW * 0.995; var s1 = size()[0] / vb.w, fitNow = vb.w >= fitW * 0.995;
// zoomed in, touch drags pan the view instead of the page (as apply() does) // zoomed in, touch drags pan the view instead of the page (as draw() does)
if (fitNow !== atFit) { atFit = fitNow; svg.style.touchAction = atFit ? "pan-y" : "none"; } if (fitNow !== atFit) { atFit = fitNow; svg.style.touchAction = atFit ? "pan-y" : "none"; }
svg.style.transform = "translate(" + ((vbC.x - vb.x) * s1).toFixed(2) + "px," + ((vbC.y - vb.y) * s1).toFixed(2) + "px) scale(" + (vbC.w / vb.w).toFixed(5) + ")"; svg.style.transform = "translate(" + ((vbC.x - vb.x) * s1).toFixed(2) + "px," + ((vbC.y - vb.y) * s1).toFixed(2) + "px) scale(" + s.toFixed(5) + ")";
clearTimeout(commitT); commitT = setTimeout(apply, 180);
} }
function apply() { // paints vb at its true scale: the viewBox, tag size and touch panning follow it
if (!vb) return; function draw() {
clearTimeout(commitT); commitT = 0;
if (live) { live = false; svg.style.transform = ""; wrap.classList.remove("iso-gesture"); }
vbC = { x: vb.x, y: vb.y, w: vb.w }; vbC = { x: vb.x, y: vb.y, w: vb.w };
drawnAt = now();
svg.style.transform = "";
svg.setAttribute("viewBox", r1(vb.x) + " " + r1(vb.y) + " " + r1(vb.w) + " " + r1(vb.h)); svg.setAttribute("viewBox", r1(vb.x) + " " + r1(vb.y) + " " + r1(vb.w) + " " + r1(vb.h));
var sz = size(), k = (vb.w / sz[0]).toFixed(4); var sz = size(), k = (vb.w / sz[0]).toFixed(4);
// tags keep their screen size by --iso-k; set on their group alone, so the rest is not restyled // tags keep their screen size by --iso-k; set on their group alone, so the rest is not restyled
if (k !== lastK) { lastK = k; var tg = svg.querySelector(".iso-tags"); (tg || svg).style.setProperty("--iso-k", k); } if (k !== lastK) { lastK = k; var tg = svg.querySelector(".iso-tags"); (tg || svg).style.setProperty("--iso-k", k); }
atFit = vb.w >= fitW * 0.995; atFit = vb.w >= fitW * 0.995;
svg.style.touchAction = atFit ? "pan-y" : "none"; svg.style.touchAction = atFit ? "pan-y" : "none";
}
function apply() {
if (!vb) return;
clearTimeout(commitT); commitT = 0;
if (live) { live = false; wrap.classList.remove("iso-gesture"); }
draw();
wrap.classList.toggle("iso-zoomed", !atFit); wrap.classList.toggle("iso-zoomed", !atFit);
schedule(); schedule();
} }