Web performance: light animation, gestures on a compositor layer, no forced layouts
Deploy web Studio / deploy (push) Successful in 3m46s
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:
1 parent
19f4c83ae8
commit
a202283a60
15 files changed
+476
-31
No files matched your search
+3
-2
@@ -38,10 +38,11 @@ wasm-bindgen = "=0.2.129"
|
||||
lto = "thin"
|
||||
codegen-units = 4
|
||||
|
||||
# the web Studio's engine (tools/build_web.py): small over fast
|
||||
# the web Studio's engine (tools/build_web.py): fast over small (opt-level 3 renders
|
||||
# about a fifth quicker than "s" for a tenth more download, 0.9 MB gzipped)
|
||||
[profile.web]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -192,6 +192,12 @@ the footprint, and `rot` turns the object in 90° steps. Levels: `site` (or any
|
||||
other id) shows buildings closed, while `gf`, `1f`, `2f` … cut them open at that
|
||||
storey.
|
||||
|
||||
Animations (fans, smoke, fish, traffic) step on about ten times a second by
|
||||
default ("light"): running CSS animations repaint the whole drawing every frame,
|
||||
which keeps a slow laptop busy. The ▷ button in the view toolbar switches
|
||||
between light, smooth and off, and the browser remembers the choice. In
|
||||
`isokit.toml`, `[build] animation = "smooth"` sets the default for built pages.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | |
|
||||
@@ -241,6 +247,7 @@ ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DI
|
||||
uv run --with playwright python tools/check_studio.py <shot-dir> http://127.0.0.1:7890 # GUI + MCP + agent window end to end (Edge)
|
||||
cargo clippy -p isokit-wasm --target wasm32-unknown-unknown -- -D warnings
|
||||
python tools/build_web.py && uv run --with playwright python tools/check_web.py <shot-dir> # web Studio end to end
|
||||
uv run --with playwright python tools/bench_web.py --rate 6 --headed # timings on a CPU slowed 6x (low-power stand-in)
|
||||
```
|
||||
|
||||
The agent window checks run on `tools/mock_llm.py`, a scripted stand-in for an
|
||||
|
||||
@@ -78,6 +78,7 @@ label = "Animation"
|
||||
[build]
|
||||
out = "dist/index.html"
|
||||
budget = 4000
|
||||
# animation = "light" # built pages: "light" (about 10 steps a second), "smooth" or "off"
|
||||
"#;
|
||||
|
||||
pub const NEW_DOC: &str = r#"{
|
||||
|
||||
@@ -83,6 +83,10 @@ pub struct BuildOpts {
|
||||
/// Node budget per view for `isokit check`.
|
||||
#[serde(default = "budget")]
|
||||
pub budget: usize,
|
||||
/// How built pages animate until the viewer picks otherwise: "light"
|
||||
/// (stepped about ten times a second, the default), "smooth" or "off".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub animation: Option<String>,
|
||||
}
|
||||
|
||||
fn budget() -> usize {
|
||||
@@ -94,6 +98,7 @@ impl Default for BuildOpts {
|
||||
BuildOpts {
|
||||
out: None,
|
||||
budget: budget(),
|
||||
animation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn static_page(p: &Project) -> Result<String, ScriptError> {
|
||||
},
|
||||
"views": views,
|
||||
"cfg": p.cfg(None),
|
||||
"animation": p.manifest.build.animation,
|
||||
});
|
||||
let css = format!(
|
||||
"{}\n{}",
|
||||
|
||||
+11
-1
@@ -71,7 +71,7 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
||||
|
||||
## Backlog (2026-10)
|
||||
|
||||
1. Web performance: the web build runs smoothly on low-power machines
|
||||
1. ~~Web performance~~ (done, see below)
|
||||
2. Conveyors: placeable conveyors
|
||||
3. Producers with adjustable production speed
|
||||
4. Robotic arms
|
||||
@@ -88,6 +88,15 @@ multi-select (Ctrl+click on the map and in the list, Shift range, Shift+drag box
|
||||
nudge, turn, copy, delete and property edits; a second click on a wall or fence selects the joined
|
||||
run); the Pond with fish swimming round (`swim` primitive, CSS rotation), lily pads, ripples, reeds.
|
||||
|
||||
Web performance, measured with `tools/bench_web.py` on the web Studio's house demo with the CPU
|
||||
slowed 6x (before → after): idle main thread in a browser window 99% → 5% (animations step about ten
|
||||
times a second instead of repainting the drawing every frame; ▷ toolbar button: light / smooth / off,
|
||||
`[build] animation`); zoom 69 → 11 ms per wheel step, pan 28 → 10 ms per move (gestures move the
|
||||
drawing as one compositor layer and draw it again when they end; no forced layouts; `--iso-k` set on
|
||||
the tags only); hover 22 → 15 ms per move (tooltip placed once per frame on its own layer, x/y
|
||||
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).
|
||||
|
||||
## Phase 2: richer editing
|
||||
|
||||
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
|
||||
@@ -116,4 +125,5 @@ run); the Pond with fish swimming round (`swim` primitive, CSS rotation), lily p
|
||||
- [ ] `isokit check` rules from the QA checklist: props on paved areas, runs ending in the air,
|
||||
covers drawn over their contents, tag overlaps
|
||||
- [ ] Performance budget test (render ms per view, 60 fps pan) on a 3,500-node scene
|
||||
(`tools/bench_web.py` measures it by hand for now)
|
||||
- [ ] Packaged desktop app (Tauri); shared projects for the hosted web Studio (behind login)
|
||||
@@ -36,3 +36,4 @@ label = "Animation"
|
||||
[build]
|
||||
out = "dist/index.html"
|
||||
budget = 4000
|
||||
# animation = "light" # built pages: "light" (about 10 steps a second), "smooth" or "off"
|
||||
@@ -61,6 +61,11 @@ input[type=checkbox] { accent-color: var(--accent); }
|
||||
.st-hint { position: absolute; top: 52px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--accent); border-radius: 16px; padding: 4px 14px; z-index: 5; pointer-events: none; }
|
||||
.st-status { display: flex; align-items: center; gap: 14px; padding: 4px 10px; background: var(--panel); border-top: 1px solid var(--stroke); color: var(--mute); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
|
||||
.st-status select { padding: 1px 4px; }
|
||||
/* the pointer readout changes on every move: a fixed box, so the page around it is not laid out again */
|
||||
#cursor, #stats { display: inline-block; contain: strict; will-change: transform; height: 1.4em; line-height: 1.4em; white-space: nowrap; overflow: hidden; }
|
||||
#cursor { width: 15ch; }
|
||||
#stats { width: 36ch; text-align: right; }
|
||||
.st-status { contain: layout style; }
|
||||
.st-ver { color: var(--mute); font-size: 11px; text-decoration: none; white-space: nowrap; }
|
||||
.st-ver:hover { color: var(--fg); text-decoration: underline; }
|
||||
.st-link { background: none; border: 0; color: var(--danger); padding: 0; text-decoration: underline; }
|
||||
@@ -162,6 +167,8 @@ input[type=checkbox] { accent-color: var(--accent); }
|
||||
/* the selection box (Shift / Ctrl + drag on the map) */
|
||||
.st-band { position: fixed; z-index: 30; pointer-events: none; border: 1px dashed var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
#objects { user-select: none; }
|
||||
/* long object lists: rows out of sight are not laid out or painted */
|
||||
#objects li { content-visibility: auto; contain-intrinsic-size: auto 28px; }
|
||||
|
||||
/* previews while placing: footprints, wall planes, where a window lands */
|
||||
.iso-svg .st-ghosts { pointer-events: none; }
|
||||
|
||||
+10
-3
@@ -251,7 +251,8 @@
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ view
|
||||
var drag = null, tool = null;
|
||||
var drag = null, tool = null, hov = null, hovRaf = 0, hovText = "", hovLate = 0;
|
||||
function showCursor() { hovLate = 0; var el = $("cursor"); if (el.textContent !== hovText) el.textContent = hovText; }
|
||||
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
||||
function mountView() {
|
||||
var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta));
|
||||
@@ -264,9 +265,15 @@
|
||||
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
|
||||
var ids = selIds(); if (ids.length) S.view.selectObj(ids.length > 1 ? ids : ids[0]);
|
||||
},
|
||||
// once per frame at most: pointers send moves faster than that; the x/y readout about ten
|
||||
// times a second (each new text costs a layout and a paint of the page)
|
||||
onHover: function (w, e) {
|
||||
if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1);
|
||||
if (S.placing && !tool && e) hoverPlacing(e);
|
||||
hov = { w: w, e: e };
|
||||
if (!hovRaf) hovRaf = requestAnimationFrame(function () {
|
||||
var h = hov; hovRaf = 0;
|
||||
if (h.w) { hovText = "x " + h.w[0].toFixed(1) + " y " + h.w[1].toFixed(1); if (!hovLate) hovLate = setTimeout(showCursor, 100); }
|
||||
if (S.placing && !tool && h.e) hoverPlacing(h.e);
|
||||
});
|
||||
},
|
||||
onPick: function (i, e) { if (i && i.obj) clickObj(i.obj, e); else select(null); },
|
||||
onKey: keyHandler,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""How the web Studio and a static page run on a slow machine.
|
||||
|
||||
Serves dist/web (build it first: python tools/build_web.py) and opens it and
|
||||
an example's static page (isokit build examples/house) in headless Edge with
|
||||
the CPU slowed down (CDP Emulation.setCPUThrottlingRate; 6x is roughly a
|
||||
low-power laptop next to a desktop). For each page it prints the load time,
|
||||
DOM size, the main thread's busy share while idle (animations), frames per
|
||||
main-thread time per input step and long frames (over 50 ms) while panning,
|
||||
zooming and hovering, and how long a
|
||||
rotation, a level switch and an edit take until painted.
|
||||
|
||||
uv run --with playwright python tools/bench_web.py [--rate 6] [--headed] [--anim light|smooth|off]
|
||||
[--json out.json] [--only studio|page]
|
||||
|
||||
Use --headed for the idle figures: headless Edge renders no frames for CSS
|
||||
animations when nothing else changes, so their cost only shows in a window.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DIST = ROOT / "dist" / "web"
|
||||
PAGE = ROOT / "examples" / "house" / "dist" / "index.html"
|
||||
PORT = 7941
|
||||
ANIM = None
|
||||
|
||||
# in the page: collect long animation frames (over 50 ms, Chromium's LoAF API);
|
||||
# no requestAnimationFrame loop, which would make headless Chrome render flat out.
|
||||
# JS_TIMED times an action until the view's SVG has new content and two frames
|
||||
# have been painted.
|
||||
JS_SETUP = """() => {
|
||||
if (window.__bench) return;
|
||||
window.__bench = { loaf: [] };
|
||||
try {
|
||||
new PerformanceObserver(l => l.getEntries().forEach(e => window.__bench.loaf.push(e.duration)))
|
||||
.observe({ type: 'long-animation-frame', buffered: false });
|
||||
} catch (e) { /* not supported */ }
|
||||
}"""
|
||||
JS_TIMED = """async (code) => {
|
||||
const svg = document.querySelector('.iso-stage .iso-svg');
|
||||
const t0 = performance.now();
|
||||
const changed = new Promise(r => { const mo = new MutationObserver(() => { mo.disconnect(); r(); }); mo.observe(svg, { childList: true }); });
|
||||
(0, eval)(code);
|
||||
const late = await Promise.race([changed.then(() => false), new Promise(r => setTimeout(() => r(true), 30000))]);
|
||||
if (late) return -1;
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
return performance.now() - t0;
|
||||
}"""
|
||||
|
||||
|
||||
T0 = time.perf_counter()
|
||||
|
||||
|
||||
def log(what):
|
||||
print(f" [{time.perf_counter() - T0:6.1f}s] {what}", flush=True)
|
||||
|
||||
|
||||
def metrics(cdp):
|
||||
m = {x["name"]: x["value"] for x in cdp.send("Performance.getMetrics")["metrics"]}
|
||||
return m
|
||||
|
||||
|
||||
def busy(cdp, pg, run, steps=1):
|
||||
"""Run `run()` (`steps` input events); answer a dict: wall seconds, the main
|
||||
thread's busy share, its ms per step, and the long frames (count, longest)."""
|
||||
pg.evaluate(JS_SETUP)
|
||||
pg.evaluate("window.__bench.loaf = []")
|
||||
m0, t0 = metrics(cdp), time.perf_counter()
|
||||
run()
|
||||
pg.wait_for_timeout(300)
|
||||
m1, t1 = metrics(cdp), time.perf_counter()
|
||||
loaf = pg.evaluate("window.__bench.loaf")
|
||||
wall, task = t1 - t0, m1["TaskDuration"] - m0["TaskDuration"]
|
||||
return {"s": wall, "busy": task / wall, "ms_step": 1000 * task / steps, "long": len(loaf), "max_ms": max(loaf or [0])}
|
||||
|
||||
|
||||
def gestures(pg, cdp, stage, out, wheel_mod=None):
|
||||
b = stage.bounding_box()
|
||||
cx, cy = b["x"] + b["width"] / 2, b["y"] + b["height"] / 2
|
||||
out["nodes"] = pg.evaluate("document.querySelectorAll('.iso-stage .iso-svg *').length")
|
||||
log("idle")
|
||||
out["idle_busy"] = busy(cdp, pg, lambda: pg.wait_for_timeout(4000))["busy"]
|
||||
|
||||
def pan():
|
||||
# the middle button: always the camera, in the Studio and on a page
|
||||
pg.mouse.move(cx - 150, cy)
|
||||
pg.mouse.down(button="middle")
|
||||
for i in range(90):
|
||||
pg.mouse.move(cx - 150 + 300 * (i % 45) / 45, cy + 60 * ((i // 45) % 2))
|
||||
pg.wait_for_timeout(16)
|
||||
pg.mouse.up(button="middle")
|
||||
|
||||
def zoom():
|
||||
pg.mouse.move(cx, cy)
|
||||
if wheel_mod:
|
||||
pg.keyboard.down(wheel_mod)
|
||||
for i in range(24):
|
||||
pg.mouse.wheel(0, -60 if i < 12 else 60)
|
||||
pg.wait_for_timeout(30)
|
||||
if wheel_mod:
|
||||
pg.keyboard.up(wheel_mod)
|
||||
|
||||
def hover():
|
||||
for i in range(90):
|
||||
pg.mouse.move(b["x"] + b["width"] * (0.2 + 0.6 * i / 90), cy + 40 * ((i // 10) % 2))
|
||||
pg.wait_for_timeout(16)
|
||||
|
||||
pg.evaluate("__isokit_view().focus([0, 0, 0], 2.2)")
|
||||
pg.wait_for_timeout(600)
|
||||
for name, fn, n in [("pan", pan, 90), ("zoom", zoom, 24), ("hover", hover, 90)]:
|
||||
log(name)
|
||||
r = busy(cdp, pg, fn, n)
|
||||
for k, v in r.items():
|
||||
out[name + "_" + k] = v
|
||||
pg.wait_for_timeout(500)
|
||||
log("rotate")
|
||||
out["rotate_ms"] = pg.evaluate(JS_TIMED, "__isokit_view().setRotation((__isokit_view().getRotation() + 1) % 4)")
|
||||
pg.wait_for_timeout(400)
|
||||
log("level")
|
||||
# to another level and back
|
||||
lv0 = pg.evaluate("__isokit_view().getLevel()")
|
||||
other = pg.evaluate("[...document.querySelectorAll('.iso-seg button')].map(b => b.dataset.level).find(l => l !== __isokit_view().getLevel())")
|
||||
out["level_ms"] = pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{other}')")
|
||||
pg.wait_for_timeout(400)
|
||||
pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{lv0}')")
|
||||
pg.wait_for_timeout(400)
|
||||
|
||||
|
||||
def open_page(b, rate):
|
||||
pg = b.new_page(viewport={"width": 1366, "height": 768})
|
||||
if ANIM:
|
||||
pg.add_init_script(f"try {{ localStorage.setItem('isokit-anim', '{ANIM}'); }} catch (e) {{}}")
|
||||
pg.bring_to_front()
|
||||
cdp = pg.context.new_cdp_session(pg)
|
||||
cdp.send("Performance.enable")
|
||||
cdp.send("Emulation.setCPUThrottlingRate", {"rate": rate})
|
||||
errs = []
|
||||
pg.on("pageerror", lambda e: errs.append(str(e)))
|
||||
return pg, cdp, errs
|
||||
|
||||
|
||||
def bench_studio(b, rate):
|
||||
out = {}
|
||||
pg, cdp, errs = open_page(b, rate)
|
||||
t0 = time.perf_counter()
|
||||
pg.goto(f"http://127.0.0.1:{PORT}/")
|
||||
pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000)
|
||||
out["load_s"] = time.perf_counter() - t0
|
||||
pg.add_init_script("window.__isokit_view = () => window.__studio.view")
|
||||
pg.evaluate("window.__isokit_view = () => window.__studio.view")
|
||||
pg.locator("#wb-projects").click()
|
||||
t0 = time.perf_counter()
|
||||
pg.locator("#wb-nhouse").click()
|
||||
pg.wait_for_load_state()
|
||||
pg.wait_for_function("window.__studio && __studio.S.doc && __studio.S.doc.objects.length > 50 && document.querySelector('.iso-svg [data-obj]')", timeout=120000)
|
||||
out["open_house_s"] = time.perf_counter() - t0
|
||||
pg.evaluate("window.__isokit_view = () => window.__studio.view")
|
||||
pg.wait_for_timeout(1000)
|
||||
gestures(pg, cdp, pg.locator(".iso-stage"), out)
|
||||
out["edit_ms"] = pg.evaluate(JS_TIMED, "__studio.op({op: 'add', type: 'tree', x: 14, y: -5})")
|
||||
pg.wait_for_timeout(300)
|
||||
out["select_ms"] = pg.evaluate("""() => { const t0 = performance.now(); __studio.select('sofa'); return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(performance.now() - t0)))); }""")
|
||||
out["errors"] = errs
|
||||
pg.close()
|
||||
return out
|
||||
|
||||
|
||||
def bench_page(b, rate):
|
||||
out = {}
|
||||
pg, cdp, errs = open_page(b, rate)
|
||||
t0 = time.perf_counter()
|
||||
pg.goto(PAGE.as_uri())
|
||||
pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000)
|
||||
out["load_s"] = time.perf_counter() - t0
|
||||
pg.evaluate("window.__isokit_view = () => window.__isokit.view")
|
||||
pg.wait_for_timeout(1000)
|
||||
gestures(pg, cdp, pg.locator(".iso-stage"), out, wheel_mod="Control")
|
||||
out["errors"] = errs
|
||||
pg.close()
|
||||
return out
|
||||
|
||||
|
||||
ROWS = [
|
||||
("load_s", "load (s)", "{:.2f}"),
|
||||
("open_house_s", "open house demo (s)", "{:.2f}"),
|
||||
("nodes", "SVG nodes", "{:.0f}"),
|
||||
("idle_busy", "idle: main thread busy", "{:.0%}"),
|
||||
("pan_ms_step", "pan: main thread ms per move", "{:.1f}"),
|
||||
("pan_busy", "pan: busy", "{:.0%}"),
|
||||
("pan_long", "pan: frames over 50 ms", "{:.0f}"),
|
||||
("pan_max_ms", "pan: longest frame (ms)", "{:.0f}"),
|
||||
("zoom_ms_step", "zoom: ms per wheel step", "{:.1f}"),
|
||||
("zoom_long", "zoom: frames over 50 ms", "{:.0f}"),
|
||||
("zoom_max_ms", "zoom: longest frame (ms)", "{:.0f}"),
|
||||
("hover_ms_step", "hover: ms per move", "{:.1f}"),
|
||||
("hover_long", "hover: frames over 50 ms", "{:.0f}"),
|
||||
("rotate_ms", "rotate until painted (ms)", "{:.0f}"),
|
||||
("level_ms", "level switch until painted (ms)", "{:.0f}"),
|
||||
("edit_ms", "edit until painted (ms)", "{:.0f}"),
|
||||
("select_ms", "select until painted (ms)", "{:.0f}"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--rate", type=float, default=6.0)
|
||||
ap.add_argument("--json")
|
||||
ap.add_argument("--only", choices=["studio", "page"])
|
||||
ap.add_argument("--headed", action="store_true")
|
||||
ap.add_argument("--anim", choices=["light", "smooth", "off"])
|
||||
a = ap.parse_args()
|
||||
global ANIM
|
||||
ANIM = a.anim
|
||||
if not (DIST / "index.html").exists():
|
||||
sys.exit("no dist/web: run python tools/build_web.py first")
|
||||
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
res = {}
|
||||
try:
|
||||
time.sleep(0.8)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(channel="msedge", headless=not a.headed, args=["--window-position=0,0"])
|
||||
if a.only != "page":
|
||||
res["studio"] = bench_studio(b, a.rate)
|
||||
if a.only != "studio" and PAGE.exists():
|
||||
res["page"] = bench_page(b, a.rate)
|
||||
b.close()
|
||||
finally:
|
||||
srv.terminate()
|
||||
cols = list(res)
|
||||
print(f"CPU slowed {a.rate:g}x, {'window' if a.headed else 'headless'}, animation {a.anim or 'default'}")
|
||||
print(f"{'':34}" + "".join(f"{c:>12}" for c in cols))
|
||||
for k, lab, fmt in ROWS:
|
||||
if any(k in res[c] for c in cols):
|
||||
print(f"{lab:34}" + "".join(f"{(fmt.format(res[c][k]) if k in res[c] else '-'):>12}" for c in cols))
|
||||
for c in cols:
|
||||
if res[c].get("errors"):
|
||||
print(c, "errors:", res[c]["errors"])
|
||||
if a.json:
|
||||
Path(a.json).write_text(json.dumps(res, indent=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -345,6 +345,45 @@ with sync_playwright() as p:
|
||||
pg.wait_for_timeout(600)
|
||||
pg.evaluate("__studio.select(null)")
|
||||
|
||||
# performance: animations step about ten times a second by default (no CSS animation runs on its
|
||||
# own), the toolbar button switches smooth / off / light; a pan moves the drawing as one layer and
|
||||
# draws it again (viewBox) only when the drag ends
|
||||
pg.evaluate("__studio.select(null)")
|
||||
pg.evaluate("localStorage.removeItem('isokit-anim')")
|
||||
an = lambda: pg.evaluate("__studio.view.animation()")
|
||||
running = lambda: pg.evaluate("document.querySelector('.iso-svg').getAnimations({subtree: true}).filter(a => a.playState === 'running').length")
|
||||
t0 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()")
|
||||
pg.wait_for_timeout(600)
|
||||
t1 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()")
|
||||
check(an() == "light" and running() == 0 and t0 is not None and 300 < t1 - t0 < 900,
|
||||
f"animations are light by default: none runs on its own, they step on ({t0} -> {t1})")
|
||||
btn = pg.locator('.iso-bar [data-a="anim"]')
|
||||
btn.click()
|
||||
pg.wait_for_timeout(500)
|
||||
check(an() == "smooth" and running() > 0 and "smooth" in btn.get_attribute("title"), f"the button switches to smooth ({running()} running)")
|
||||
btn.click()
|
||||
pg.wait_for_timeout(300)
|
||||
check(an() == "off" and running() == 0, "then off")
|
||||
btn.click()
|
||||
pg.wait_for_timeout(300)
|
||||
check(an() == "light" and pg.evaluate("localStorage.getItem('isokit-anim')") == "light", "then light again, remembered")
|
||||
vb0 = pg.evaluate("document.querySelector('.iso-svg').getAttribute('viewBox')")
|
||||
bx = pg.locator(".iso-stage").bounding_box()
|
||||
cx2, cy2 = bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2
|
||||
pg.mouse.move(cx2, cy2)
|
||||
pg.mouse.down(button="middle")
|
||||
for i in range(1, 9):
|
||||
pg.mouse.move(cx2 + 10 * i, cy2 + 4 * i)
|
||||
pg.wait_for_timeout(20)
|
||||
mid = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]")
|
||||
pg.mouse.up(button="middle")
|
||||
pg.wait_for_timeout(300)
|
||||
end = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]")
|
||||
check(mid[0] == vb0 and "translate" in mid[1] and mid[2] and end[0] != vb0 and end[1] == "" and not end[2],
|
||||
f"a pan moves the layer (viewBox kept, transform {mid[1][:40]}), the release draws it again")
|
||||
check(pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain").startswith("strict") or "size" in pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain"),
|
||||
"the pointer readout is a contained box (its text does not lay the page out again)")
|
||||
|
||||
# the middle mouse button only moves the camera: it never drags, selects or places anything
|
||||
pg.locator('#objects li[data-id="truck"]').click()
|
||||
pg.evaluate("__studio.view.focus('truck', 3)")
|
||||
|
||||
@@ -300,6 +300,14 @@ try:
|
||||
check(pg.evaluate("() => __studio.S.warnings.length") == 0 and pg.locator('.st-tile[data-type="@room"]').count() == 1,
|
||||
"it draws without warnings, the palette has the Room tool")
|
||||
shot(pg, "web-house.jpg")
|
||||
# low-power default: the pond's fish (and every other animation) step on about ten times a
|
||||
# second instead of running as CSS animations that repaint the drawing every frame
|
||||
an = pg.evaluate("""() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true});
|
||||
return [__studio.view.animation(), a.length, a.filter(x => x.playState === 'running').length, a.length ? a[0].currentTime : 0]; }""")
|
||||
pg.wait_for_timeout(500)
|
||||
t1 = pg.evaluate("() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : 0; }")
|
||||
check(an[0] == "light" and an[1] >= 6 and an[2] == 0 and t1 > an[3] and pg.locator('.iso-bar [data-a="anim"]').is_visible(),
|
||||
f"animations are light by default: {an[1]} stepped, none running, time moves on; the toolbar can switch")
|
||||
|
||||
# ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder)
|
||||
demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8")
|
||||
|
||||
+13
-4
@@ -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
@@ -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">−</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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
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) : "") +
|
||||
|
||||
Reference in new issue
Block a user