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
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
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
+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")
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
# (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")
+21 -9
View File
@@ -250,30 +250,42 @@
}
// 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.
// apply() commits vb as the viewBox (one repaint) when the gesture ends, and for every other change.
var vbC = null, live = false, commitT = 0, lastK = null;
// That layer is a picture at the scale last drawn, so zooming in stretches it (blurry) and
// 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() {
if (!vb) return;
if (!vbC) { apply(); return; }
if (!live) wrap.classList.add("iso-gesture");
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;
// 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"; }
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) + ")";
clearTimeout(commitT); commitT = setTimeout(apply, 180);
svg.style.transform = "translate(" + ((vbC.x - vb.x) * s1).toFixed(2) + "px," + ((vbC.y - vb.y) * s1).toFixed(2) + "px) scale(" + s.toFixed(5) + ")";
}
function apply() {
if (!vb) return;
clearTimeout(commitT); commitT = 0;
if (live) { live = false; svg.style.transform = ""; wrap.classList.remove("iso-gesture"); }
// paints vb at its true scale: the viewBox, tag size and touch panning follow it
function draw() {
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));
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
if (k !== lastK) { lastK = k; var tg = svg.querySelector(".iso-tags"); (tg || svg).style.setProperty("--iso-k", k); }
atFit = vb.w >= fitW * 0.995;
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);
schedule();
}