Web performance: light animation, gestures on a compositor layer, no forced layouts
Deploy web Studio / deploy (push) Successful in 3m46s

Measured with the new tools/bench_web.py on the web Studio's house demo, CPU slowed 6x:

- Idle: running CSS animations repainted the whole drawing every frame (99% of the main
  thread in a window). Animations now step on about ten times a second by default
  ("light", 5% idle); the new toolbar button switches light / smooth / off and the browser
  remembers it; [build] animation sets the default for built pages; view.animation().
  Pan and zoom gestures pause them. Status LEDs pause off-screen and with the rest now.
- Pan 28 -> 10 ms, zoom 69 -> 11 ms per step: gestures move the drawing as one compositor
  layer (transform) and set the viewBox once they end; --iso-k is set on the tags group
  only and only when it changes; the stage box is read once per frame.
- Hover 22 -> 15 ms per move: the tooltip is placed once per frame on its own layer and
  measured only when its text changes; the Studio's x/y readout updates ten times a second
  in a contained box; hover work in the Studio runs once per frame.
- Long object lists skip layout of rows out of sight (content-visibility).
- WASM built with opt-level 3: engine render 11.7 -> 9.4 ms for 10% more download.

Checks: Studio 102/102 (animation modes, pan as a layer, contained readout), web 87/87.
This commit is contained in:
grabowski committed 2026-10-24 20:46:35 +07:00
1 parent 19f4c83ae8
commit a202283a60
15 files changed
+476 -31

No files matched your search

+13 -4
View File
@@ -18,6 +18,9 @@
linear-gradient(180deg, var(--iso-sky1), var(--iso-sky2));
}
.iso-svg { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; outline: none; }
/* its own compositor layer: pan and zoom gestures move it by a transform (no repaint) until they end;
drawn past its box so the ground just outside the view is there while panning (the stage clips) */
.iso-svg { will-change: transform; transform-origin: 0 0; overflow: visible; }
.iso-dragging .iso-svg { cursor: grabbing; }
.iso-svg:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: -2px; }
.iso-bar {
@@ -42,7 +45,8 @@
.iso-compass path { fill: var(--iso-accent); }
.iso-compass b { font-size: 11px; }
.iso-tip {
position: absolute; z-index: 3; max-width: min(330px, calc(100% - 12px)); padding: 8px 10px; pointer-events: none;
position: absolute; left: 0; top: 0; z-index: 3; max-width: min(330px, calc(100% - 12px)); padding: 8px 10px; pointer-events: none;
will-change: transform; contain: layout style;
background: var(--iso-tipbg); color: var(--iso-tipfg); border: 1px solid var(--iso-tipline); border-radius: 6px;
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; box-shadow: 0 4px 14px rgba(0, 0, 0, .28);
}
@@ -112,8 +116,13 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
.iso-svg .iso-swim { transform-box: fill-box; transform-origin: center; animation: iso-spin 20s linear infinite; }
.iso-svg .iso-rip { transform-box: fill-box; transform-origin: center; animation: iso-rip 5.1s ease-out infinite; }
.iso-svg .iso-park { display: none; }
.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive { animation-play-state: paused !important; }
.iso-still .iso-park { display: inline; }
/* paused off-screen (iso-off), when stopped (iso-still), with animation off and during pan/zoom gestures */
.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive, .iso-off .iso-ledr, .iso-off .iso-ledo, .iso-still .iso-ledr, .iso-still .iso-ledo,
.iso-anim-off .iso-a, .iso-anim-off .iso-drive, .iso-anim-off .iso-ledr, .iso-anim-off .iso-ledo,
.iso-gesture .iso-a, .iso-gesture .iso-drive, .iso-gesture .iso-ledr, .iso-gesture .iso-ledo { animation-play-state: paused !important; }
.iso-still .iso-park, .iso-anim-off .iso-park { display: inline; }
.iso-animbtn { min-width: 30px; font-size: 13px; }
.iso-bar [hidden] { display: none !important; }
@keyframes iso-spin { to { transform: rotate(360deg); } }
@keyframes iso-puff {
0% { transform: translate(0, 0) scale(.35); opacity: 0; }
@@ -136,7 +145,7 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
96%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.iso-svg .iso-a { animation: none !important; }
.iso-svg .iso-a, .iso-svg .iso-ledr, .iso-svg .iso-ledo { animation: none !important; }
.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke, .iso-svg .iso-disc, .iso-svg .iso-glint, .iso-svg .iso-rip { display: none; }
.iso-svg .iso-park { display: inline; }
}
+118 -21
View File
@@ -14,7 +14,7 @@
* view.update(cfg) view.refresh() view.setLevel(id) view.setRotation(r) view.rotate(d)
* view.select(id) view.selectObj(id | [ids]) view.focus(id | [x, y, z], zoom) view.fit()
* view.toWorld(clientX, clientY, z) -> [x, y] view.ready() -> Promise
* view.info(id) view.stats() view.fullscreen(on) view.destroy()
* view.info(id) view.stats() view.fullscreen(on) view.animation("light" | "smooth" | "off") view.destroy()
*/
(function (root) {
"use strict";
@@ -89,6 +89,7 @@
'<button type="button" data-a="zout" title="Zoom out" aria-label="Zoom out">&#x2212;</button>' +
'<button type="button" data-a="zin" title="Zoom in" aria-label="Zoom in">+</button>' +
'<button type="button" data-a="fit" title="Show everything" aria-label="Fit the view">Fit</button>' +
'<button type="button" data-a="anim" class="iso-animbtn" hidden></button>' +
(opts.fullscreen === false ? "" : '<button type="button" data-a="full" class="iso-fullbtn" title="Full screen (F)" aria-label="Full screen" aria-pressed="false">' +
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path class="iso-fs-in" d="M1.5 6V1.5H6M10 1.5h4.5V6M14.5 10v4.5H10M6 14.5H1.5V10"/>' +
'<path class="iso-fs-out" d="M6 1.5V6H1.5M10 1.5V6h4.5M14.5 10H10v4.5M1.5 10H6v4.5"/></svg></button>') + "</div>" +
@@ -133,8 +134,62 @@
return undefined;
}
// ---------------------------------------------------------- animation
// CSS animations inside the drawing repaint all of it every frame: a fifth of a fast core,
// all of a slow one. "light" (the default) pauses them and steps them on together about ten
// times a second, "smooth" lets them run, "off" stops them. Pan and zoom gestures pause them.
var ANIM = ["light", "smooth", "off"], ANIM_UI = {
light: ["\u25B7", "Animation: light, about 10 steps a second (click: smooth)"],
smooth: ["\u25B6", "Animation: smooth (click: off)"],
off: ["\u2016", "Animation: off (click: light)"]
};
var animMode = (function () {
var m = null;
try { m = localStorage.getItem("isokit-anim"); } catch (e) { /* storage blocked */ }
return ANIM.indexOf(m) >= 0 ? m : ANIM.indexOf(opts.animation) >= 0 ? opts.animation : "light";
})();
var animTimer = 0, animLast = 0, animList = [], animBtn = bar.querySelector('[data-a="anim"]');
var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
// after every new scene and every mode change
function animSetup() {
clearInterval(animTimer); animTimer = 0; animList = [];
wrap.classList.toggle("iso-anim-off", animMode === "off");
if (animBtn) {
animBtn.hidden = reducedMotion || !svg.querySelector(".iso-a, .iso-drive, .iso-ledr, .iso-ledo");
animBtn.textContent = ANIM_UI[animMode][0]; animBtn.title = ANIM_UI[animMode][1];
animBtn.setAttribute("aria-label", ANIM_UI[animMode][1]);
}
if (animMode !== "light" || !svg.getAnimations) return;
animList = svg.getAnimations({ subtree: true });
if (!animList.length) return;
animList.forEach(function (a) { a.pause(); });
animLast = performance.now();
animTimer = setInterval(animStep, 100);
}
function animStep() {
var now = performance.now(), dt = now - animLast;
animLast = now;
if (live || document.hidden || wrap.classList.contains("iso-off") || wrap.classList.contains("iso-still")) return;
for (var i = 0; i < animList.length; i++) animList[i].currentTime = (animList[i].currentTime || 0) + dt;
}
function setAnim(m) {
if (ANIM.indexOf(m) < 0 || m === animMode) return;
var was = animMode;
animMode = m;
try { localStorage.setItem("isokit-anim", m); } catch (e) { /* storage blocked */ }
// animations paused by script ignore CSS from then on: draw the scene again to let them run
if (was === "light" && m === "smooth" && scn) applyScene(scn); else animSetup();
call(opts.onAnimation, m);
}
// ---------------------------------------------------------- build
function size() { var r = stage.getBoundingClientRect(); return [Math.max(1, r.width), Math.max(1, r.height)]; }
// the stage's box, read at most once per frame: reading it after a DOM write forces a layout
var rc = null;
function stageRect() {
if (!rc) { rc = stage.getBoundingClientRect(); (window.requestAnimationFrame || setTimeout)(function () { rc = null; }); }
return rc;
}
function size() { var r = stageRect(); return [Math.max(1, r.width), Math.max(1, r.height)]; }
function build() {
var my = ++seq;
wrap.classList.add("iso-loading");
@@ -160,6 +215,7 @@
var hadScene = !!scn;
scn = d;
svg.innerHTML = d.html;
lastK = null;
wrap.classList.toggle("iso-still", st.cfg.running === false);
metas = Array.prototype.map.call(svg.querySelectorAll(".iso-tag"), function (g, i) {
return { el: g, p: +g.getAttribute("data-p"), w: +g.getAttribute("data-w"), x: +g.getAttribute("data-x"), y: +g.getAttribute("data-y"), i: i, hid: false };
@@ -171,6 +227,7 @@
if (first || !hadScene) { first = false; lastSize = size(); focusDefault(); }
else if (pendingFrame) { var f = pendingFrame; pendingFrame = null; f(); }
else { var wasFit = atFit; computeFit(); if (wasFit) fit(); else { clampVB(); apply(); } }
animSetup();
call(opts.onRender, d);
flush();
}
@@ -182,11 +239,30 @@
fitW = w;
return { x: b[0] + b[2] / 2 - w / 2, y: b[1] + b[3] / 2 - h / 2, w: w, h: h };
}
// 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;
function applyLive() {
if (!vb) return;
if (!vbC) { apply(); return; }
if (!live) wrap.classList.add("iso-gesture");
live = true;
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)
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);
}
function apply() {
if (!vb) return;
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 };
svg.setAttribute("viewBox", r1(vb.x) + " " + r1(vb.y) + " " + r1(vb.w) + " " + r1(vb.h));
var sz = size();
svg.style.setProperty("--iso-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
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";
wrap.classList.toggle("iso-zoomed", !atFit);
@@ -198,7 +274,7 @@
cx = Math.max(b[0], Math.min(b[0] + b[2], cx)); cy = Math.max(b[1], Math.min(b[1] + b[3], cy));
vb.x = cx - vb.w / 2; vb.y = cy - vb.h / 2;
}
function zoomAt(f, px, py) {
function zoomAt(f, px, py, gesture) {
if (!vb) return;
touched = true;
var sz = size(), nw = Math.max(fitW / 20, Math.min(fitW, vb.w / f)), nh = nw * sz[1] / sz[0];
@@ -206,10 +282,10 @@
var ux = vb.x + px * vb.w, uy = vb.y + py * vb.h;
vb = { x: ux - px * nw, y: uy - py * nh, w: nw, h: nh };
if (nw >= fitW * 0.999) vb = computeFit(); else clampVB();
apply();
if (gesture) applyLive(); else apply();
}
function zoomClient(f, cx, cy) { var r = stage.getBoundingClientRect(); zoomAt(f, (cx - r.left) / r.width, (cy - r.top) / r.height); }
function panPx(dx, dy) { if (!vb) return; touched = true; var sz = size(), k = vb.w / sz[0]; vb.x -= dx * k; vb.y -= dy * k; clampVB(); apply(); }
function zoomClient(f, cx, cy, gesture) { var r = stageRect(); zoomAt(f, (cx - r.left) / r.width, (cy - r.top) / r.height, gesture); }
function panPx(dx, dy, gesture) { if (!vb) return; touched = true; var sz = size(), k = vb.w / sz[0]; vb.x -= dx * k; vb.y -= dy * k; clampVB(); if (gesture) applyLive(); else apply(); }
function focusDefault() {
fit();
var sz = size();
@@ -240,16 +316,31 @@
// ---------------------------------------------------------- picking
function infoOf(id) { return (scn && scn.info && scn.info[id]) || { id: id, name: id, detail: "" }; }
// the tooltip: placed once per frame, in the frame's own callback (pointer handlers only note
// where); its text is rebuilt and measured only for another object, following the pointer only
// moves it (a transform on its own layer: no layout, no repaint of the drawing below)
var tipKey = null, tipW = 0, tipH = 0, tipNext = null, tipRaf = 0;
function showTip(info, cx, cy) {
var rows = info.rows && info.rows.length ? "<table>" + info.rows.map(function (r) { return "<tr><th>" + esc(r[0]) + "</th><td>" + esc(r[1]) + "</td></tr>"; }).join("") + "</table>" : "";
tip.innerHTML = "<b>" + esc(info.name) + "</b>" + (info.detail ? "<span>" + esc(info.detail) + "</span>" : "") + rows;
tip.hidden = false;
var r = stage.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight, x = cx - r.left + 14, y = cy - r.top + 14;
if (x + tw > r.width - 6) x = Math.max(6, cx - r.left - tw - 14);
if (y + th > r.height - 6) y = Math.max(6, cy - r.top - th - 14);
tip.style.left = x + "px"; tip.style.top = y + "px";
tipNext = { info: info, cx: cx, cy: cy };
if (!tipRaf) tipRaf = (window.requestAnimationFrame || setTimeout)(placeTip);
}
function hideTip() { tip.hidden = true; }
function placeTip() {
tipRaf = 0;
var t = tipNext, info = t && t.info;
tipNext = null;
if (!t) return;
var rows = info.rows && info.rows.length ? "<table>" + info.rows.map(function (r) { return "<tr><th>" + esc(r[0]) + "</th><td>" + esc(r[1]) + "</td></tr>"; }).join("") + "</table>" : "";
var html = "<b>" + esc(info.name) + "</b>" + (info.detail ? "<span>" + esc(info.detail) + "</span>" : "") + rows;
if (tip.hidden || html !== tipKey) {
tip.innerHTML = html; tipKey = html; tip.hidden = false;
tipW = tip.offsetWidth; tipH = tip.offsetHeight;
}
var r = stageRect(), x = t.cx - r.left + 14, y = t.cy - r.top + 14;
if (x + tipW > r.width - 6) x = Math.max(6, t.cx - r.left - tipW - 14);
if (y + tipH > r.height - 6) y = Math.max(6, t.cy - r.top - tipH - 14);
tip.style.transform = "translate(" + Math.round(x) + "px," + Math.round(y) + "px)";
}
function hideTip() { tipNext = null; tip.hidden = true; }
function q(attr, id) { return svg.querySelectorAll("[" + attr + '="' + String(id).replace(/["\\]/g, "") + '"]'); }
function select(id, quiet) {
Array.prototype.forEach.call(svg.querySelectorAll(".iso-sel"), function (e) { e.classList.remove("iso-sel"); });
@@ -279,7 +370,7 @@
// client point -> world plan at height z
function toWorld(cx, cy, z) {
if (!vb) return null;
var r = stage.getBoundingClientRect(), sx = vb.x + (cx - r.left) / r.width * vb.w, sy = vb.y + (cy - r.top) / r.height * vb.h;
var r = stageRect(), sx = vb.x + (cx - r.left) / r.width * vb.w, sy = vb.y + (cy - r.top) / r.height * vb.h;
return unproject(st.rot, sx, sy, z || 0);
}
function screenPx() { var sz = size(); return vb ? vb.w / sz[0] : 1; }
@@ -312,7 +403,7 @@
p.x = e.clientX; p.y = e.clientY;
if (pinch && npt() >= 2) {
var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]], d = Math.hypot(a.x - b.x, a.y - b.y) || 1;
zoomClient(d / pinch.d, (a.x + b.x) / 2, (a.y + b.y) / 2); pinch.d = d; return;
zoomClient(d / pinch.d, (a.x + b.x) / 2, (a.y + b.y) / 2, true); pinch.d = d; return;
}
if (down && !moved && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 4) {
moved = true; hideTip(); wrap.classList.add("iso-dragging");
@@ -320,7 +411,7 @@
}
if (moved) {
if (claimed) call(opts.interact.move, e, { world: toWorld(e.clientX, e.clientY, 0), dx: e.clientX - down.x, dy: e.clientY - down.y, k: screenPx() });
else panPx(dx, dy);
else panPx(dx, dy, true);
e.preventDefault();
}
return;
@@ -338,6 +429,7 @@
}
function up(e) {
var wasDown = down && down.id === e.pointerId;
if (live) apply();
delete ptrs[e.pointerId];
if (npt() < 2) pinch = null;
if (wasDown) {
@@ -355,7 +447,7 @@
svg.addEventListener("wheel", function (e) {
if (!(e.ctrlKey || e.metaKey || opts.wheelZoom)) return;
e.preventDefault();
zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY);
zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY, true);
}, { passive: false });
svg.addEventListener("dblclick", function (e) {
e.preventDefault();
@@ -380,6 +472,7 @@
else if (a === "rotl") rotate(-1); else if (a === "rotr") rotate(1);
else if (a === "zin") zoomAt(1.5); else if (a === "zout") zoomAt(1 / 1.5); else if (a === "fit") fit();
else if (a === "full") setFull(!isFull());
else if (a === "anim") setAnim(ANIM[(ANIM.indexOf(animMode) + 1) % ANIM.length]);
});
function setLevel(l) {
@@ -402,6 +495,7 @@
}
var ro = null, io = null;
function onResize() {
rc = null;
if (!scn) return;
var sz = size();
if (Math.abs(sz[0] - lastSize[0]) < 0.5 && Math.abs(sz[1] - lastSize[1]) < 0.5) return;
@@ -431,6 +525,8 @@
setSource: function (s) { source = s; build(); return view; },
setLevel: function (l) { setLevel(l); return view; },
getLevel: function () { return st.level; },
// "light" | "smooth" | "off"; without an argument: the current mode
animation: function (m) { if (m === undefined) return animMode; setAnim(m); return view; },
rotate: function (d) { rotate(d); return view; },
setRotation: function (r) { var d = rotOf(r - st.rot); if (d) rotate(d); return view; },
getRotation: function () { return st.rot; },
@@ -467,7 +563,7 @@
ready: function () { return new Promise(function (res) { if (!wrap.classList.contains("iso-loading") && scn) res(view); else waiting.push(function () { res(view); }); }); },
info: function (id) { return id ? infoOf(id) : Object.keys((scn && scn.info) || {}).map(infoOf); },
config: function () { return Object.assign({}, st.cfg); },
stats: function () { return { nodes: svg.querySelectorAll("*").length, sprites: scn ? scn.sprites : 0, level: st.level, rotation: st.rot, ms: scn ? scn.ms : 0 }; },
stats: function () { return { nodes: svg.querySelectorAll("*").length, sprites: scn ? scn.sprites : 0, level: st.level, rotation: st.rot, ms: scn ? scn.ms : 0, animation: animMode, animations: animList.length }; },
destroy: function () {
alive = false;
if (ro) ro.disconnect(); else window.removeEventListener("resize", onResize);
@@ -476,6 +572,7 @@
document.removeEventListener("webkitfullscreenchange", fsSync);
document.removeEventListener("keydown", onFsKey);
if (io) io.disconnect();
clearInterval(animTimer);
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
}
};
+1
View File
@@ -48,6 +48,7 @@ document.documentElement.setAttribute("data-theme",t);})();
function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
var view = IsoKit.mount(document.getElementById("view"), IsoKit.staticSource(data), {
fullscreen: document.querySelector(".card"),
animation: data.animation || undefined,
onPick: function (i) {
if (!i) { picked.textContent = "Tap or click anything for its details."; return; }
picked.innerHTML = "<b>" + esc(i.name) + "</b>" + (i.detail ? "<br>" + esc(i.detail) : "") +