Deploy web Studio / deploy (push) Successful in 1m3s
With Windows "Animation effects" off, browsers report prefers-reduced-motion, and the viewer
turned every animation off and hid the animation button, so presses, belts and robot arms
stood still in Brave and Firefox with no way to start them. Now that preference only picks
the starting mode ("off"); the button stays (its tooltip says why it is off) and the
viewer's choice wins and is remembered. The CSS still-picture rules apply only until the
viewer turns animation on (.iso-anim-on).
Also: switching light -> off -> smooth redraws the scene so the animations paused by script
run again (they ignore CSS play states).
Checks: web 93/93 (reduced motion: starts still, one click runs it, kept after reload),
Studio 109/109, 70 Rust tests. Tried in real Brave (system setting) and Firefox.
593 lines
33 KiB
JavaScript
593 lines
33 KiB
JavaScript
/* isokit-view.js: browser shell for isokit scenes (global IsoKit) */
|
|
/*
|
|
* Plain ES2019, no dependencies. The Rust engine renders SVG; this file shows
|
|
* it: levels, four rotations, drag pan, pinch / wheel zoom, tag declutter,
|
|
* tooltips, selection, full screen. Port of the CNXIso / Mooban mount().
|
|
*
|
|
* var view = IsoKit.mount(el, source, { onPick: function (info) {} });
|
|
* source = IsoKit.staticSource(data) // prerendered views (isokit build)
|
|
* | IsoKit.httpSource("", meta) // the Studio / dev server
|
|
* source.meta = { title, note, levels: [{id, label, title}], defaultLevel, defaultRotation }
|
|
* source.render(level, rot, cfg) -> view data or a Promise of it:
|
|
* { html, info, bbox: [x, y, w, h], focus: [x, y], north, boxes, warnings }
|
|
*
|
|
* 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.animation("light" | "smooth" | "off") view.destroy()
|
|
*/
|
|
(function (root) {
|
|
"use strict";
|
|
|
|
var VERSION = "0.1.0";
|
|
var C = 4, CZ = 4;
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
function r1(v) { return Math.round(v * 10) / 10; }
|
|
function rotOf(r) { return ((r | 0) % 4 + 4) % 4; }
|
|
// view coordinates -> world plan (inverse of the engine's Proj.r)
|
|
function rInv(rot, vx, vy) {
|
|
switch (rot) { case 0: return [vx, -vy]; case 1: return [-vy, -vx]; case 2: return [-vx, vy]; default: return [vy, vx]; }
|
|
}
|
|
function rFwd(rot, x, y) {
|
|
switch (rot) { case 0: return [x, -y]; case 1: return [-y, -x]; case 2: return [-x, y]; default: return [y, x]; }
|
|
}
|
|
function project(rot, x, y, z) { var v = rFwd(rot, x, y); return [(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2 - (z || 0) * CZ]; }
|
|
function unproject(rot, sx, sy, z) {
|
|
var a = sx / C, b = (sy + (z || 0) * CZ) * 2 / C;
|
|
return rInv(rot, (a + b) / 2, (b - a) / 2);
|
|
}
|
|
function isThen(x) { return x && typeof x.then === "function"; }
|
|
|
|
// ------------------------------------------------------------ sources
|
|
function staticSource(data) {
|
|
return {
|
|
meta: data.meta,
|
|
render: function (level, rot) {
|
|
var v = data.views[level + "/" + rot];
|
|
return v || { error: { message: "view " + level + "/" + rot + " was not built" } };
|
|
}
|
|
};
|
|
}
|
|
function httpSource(base, meta) {
|
|
base = base || "";
|
|
return {
|
|
meta: meta,
|
|
dev: true,
|
|
render: function (level, rot, cfg) {
|
|
var q = "level=" + encodeURIComponent(level) + "&rot=" + rot + "&dev=1" + (cfg ? "&cfg=" + encodeURIComponent(JSON.stringify(cfg)) : "");
|
|
return fetch(base + "/api/render?" + q, { cache: "no-store" }).then(function (r) { return r.json(); });
|
|
}
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------ mount
|
|
var UID = 0;
|
|
function mount(el, source, opts) {
|
|
if (!el) throw new Error("IsoKit.mount: no container");
|
|
opts = opts || {};
|
|
var meta = source.meta || {};
|
|
var levels = meta.levels && meta.levels.length ? meta.levels : [{ id: "site", label: "Site" }];
|
|
var st = {
|
|
cfg: Object.assign({}, opts.cfg || {}),
|
|
level: opts.level || meta.defaultLevel || levels[0].id,
|
|
rot: rotOf(opts.rotation === undefined ? (meta.defaultRotation === undefined ? 1 : meta.defaultRotation) : opts.rotation),
|
|
sel: null, selObj: null, uid: ++UID
|
|
};
|
|
var wrap = document.createElement("div");
|
|
wrap.className = "iso-wrap";
|
|
var segs = levels.length > 1 ? '<span class="iso-seg" role="group" aria-label="Level">' + levels.map(function (l) {
|
|
return '<button type="button" data-a="level" data-level="' + esc(l.id) + '" title="' + esc(l.title || l.label || l.id) + '">' + esc(l.label || l.id) + "</button>";
|
|
}).join("") + "</span>" : "";
|
|
wrap.innerHTML = '<div class="iso-stage"><svg class="iso-svg" xmlns="http://www.w3.org/2000/svg" role="img" tabindex="0" aria-label="' + esc(meta.title || "Isometric view") + '"></svg>' +
|
|
'<div class="iso-bar" role="toolbar" aria-label="View">' + segs +
|
|
'<button type="button" data-a="rotl" title="Rotate left" aria-label="Rotate the view left">↺</button>' +
|
|
'<button type="button" data-a="rotr" title="Rotate right" aria-label="Rotate the view right">↻</button>' +
|
|
'<span class="iso-compass" title="North" aria-hidden="true"><svg viewBox="-10 -10 20 20"><g class="iso-needle"><path d="M0 -8L3 3L0 1.4L-3 3Z"/></g></svg><b>N</b></span>' +
|
|
'<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>" +
|
|
'<div class="iso-tip" role="status" hidden></div><div class="iso-err" hidden></div><div class="iso-busy" hidden></div></div>' +
|
|
(opts.note === false || !meta.note ? "" : '<p class="iso-note">' + esc(meta.note) + "</p>");
|
|
el.appendChild(wrap);
|
|
var stage = wrap.querySelector(".iso-stage"), svg = wrap.querySelector(".iso-svg"), tip = wrap.querySelector(".iso-tip"),
|
|
bar = wrap.querySelector(".iso-bar"), errBox = wrap.querySelector(".iso-err");
|
|
var scn = null, vb = null, fitW = 1, atFit = true, metas = [], declT = 0, alive = true, lastSize = [0, 0], seq = 0, waiting = [];
|
|
var touched = false, first = true;
|
|
|
|
// ---------------------------------------------------------- full screen
|
|
var fsEl = opts.fullscreen && opts.fullscreen.nodeType === 1 ? opts.fullscreen : wrap, fsBtn = bar.querySelector('[data-a="full"]');
|
|
function fsNative() { return (document.fullscreenElement || document.webkitFullscreenElement) === fsEl; }
|
|
function isFull() { return fsNative() || fsEl.classList.contains("iso-fs-fake"); }
|
|
function fsSync() {
|
|
var on = isFull();
|
|
fsEl.classList.toggle("iso-fs", on);
|
|
if (fsBtn) { fsBtn.setAttribute("aria-pressed", String(on)); fsBtn.title = on ? "Leave full screen (Esc)" : "Full screen (F)"; }
|
|
call(opts.onFullscreen, on);
|
|
}
|
|
function fsFake(on) { fsEl.classList.toggle("iso-fs-fake", on); document.documentElement.classList.toggle("iso-fs-lock", on); fsSync(); }
|
|
function setFull(on) {
|
|
if (on === isFull()) return;
|
|
if (!on) {
|
|
if (fsEl.classList.contains("iso-fs-fake")) fsFake(false);
|
|
else { var ex = document.exitFullscreen || document.webkitExitFullscreen; if (ex) ex.call(document); }
|
|
return;
|
|
}
|
|
var req = fsEl.requestFullscreen || fsEl.webkitRequestFullscreen;
|
|
if (!req) { fsFake(true); return; }
|
|
try { var p = req.call(fsEl); if (p && p.catch) p.catch(function () { fsFake(true); }); } catch (e) { fsFake(true); }
|
|
}
|
|
function onFsKey(e) { if (e.key === "Escape" && fsEl.classList.contains("iso-fs-fake")) { fsFake(false); svg.focus(); } }
|
|
document.addEventListener("fullscreenchange", fsSync);
|
|
document.addEventListener("webkitfullscreenchange", fsSync);
|
|
document.addEventListener("keydown", onFsKey);
|
|
|
|
function call(fn) {
|
|
if (typeof fn !== "function") return undefined;
|
|
try { return fn.apply(null, Array.prototype.slice.call(arguments, 1)); } catch (e) { if (window.console) console.error(e); }
|
|
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.
|
|
// A system asking for less motion (Windows: Animation effects off) starts them off, but the
|
|
// button stays: what the viewer picks there wins and is remembered.
|
|
var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
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", reducedMotion ? "Animation: off, as your system asks for less motion (click: on)" : "Animation: off (click: light)"]
|
|
};
|
|
var animMode = (function () {
|
|
var m = null;
|
|
try { m = localStorage.getItem("isokit-anim"); } catch (e) { /* storage blocked */ }
|
|
if (ANIM.indexOf(m) >= 0) return m;
|
|
if (reducedMotion) return "off";
|
|
return ANIM.indexOf(opts.animation) >= 0 ? opts.animation : "light";
|
|
})();
|
|
// animScripted: the animations of this drawing were paused by script (light mode) and ignore
|
|
// CSS play states from then on, so smooth needs the scene drawn again
|
|
var animTimer = 0, animLast = 0, animList = [], animScripted = false, animBtn = bar.querySelector('[data-a="anim"]');
|
|
// after every new scene and every mode change
|
|
function animSetup() {
|
|
clearInterval(animTimer); animTimer = 0; animList = [];
|
|
wrap.classList.toggle("iso-anim-off", animMode === "off");
|
|
// the viewer turned them on: past the reduced-motion rules in the CSS
|
|
wrap.classList.toggle("iso-anim-on", animMode !== "off");
|
|
if (animBtn) {
|
|
animBtn.hidden = !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(); });
|
|
animScripted = true;
|
|
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 (m === "smooth" && animScripted && scn) applyScene(scn); else animSetup();
|
|
call(opts.onAnimation, m);
|
|
}
|
|
|
|
// ---------------------------------------------------------- build
|
|
// 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");
|
|
var r;
|
|
try { r = source.render(st.level, st.rot, st.cfg); } catch (e) { r = { error: { message: String(e) } }; }
|
|
if (isThen(r)) {
|
|
r.then(function (d) { if (my === seq) applyScene(d); }, function (e) { if (my === seq) applyScene({ error: { message: String(e) } }); });
|
|
} else applyScene(r);
|
|
}
|
|
function applyScene(d) {
|
|
if (!alive) return;
|
|
wrap.classList.remove("iso-loading");
|
|
if (!d || d.error) {
|
|
var e = (d && d.error) || { message: "no data" };
|
|
errBox.innerHTML = "<b>Scene error</b><span>" + esc((e.file ? e.file + (e.line ? ":" + e.line : "") + ": " : "") + e.message) + "</span>" +
|
|
(e.trace && e.trace.length ? "<small>" + e.trace.map(esc).join("<br>") + "</small>" : "");
|
|
errBox.hidden = false;
|
|
call(opts.onRender, d);
|
|
flush();
|
|
return;
|
|
}
|
|
errBox.hidden = true;
|
|
var hadScene = !!scn;
|
|
scn = d;
|
|
svg.innerHTML = d.html;
|
|
lastK = null; animScripted = false;
|
|
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 };
|
|
}).sort(function (a, b) { return b.p - a.p || a.i - b.i; });
|
|
Array.prototype.forEach.call(bar.querySelectorAll(".iso-seg button"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-level") === st.level)); });
|
|
wrap.querySelector(".iso-needle").setAttribute("transform", "rotate(" + (d.north || 0).toFixed(1) + ")");
|
|
if (st.sel) select(st.sel, true);
|
|
if (st.selObj) selectObj(st.selObj);
|
|
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();
|
|
}
|
|
var pendingFrame = null;
|
|
function flush() { var w = waiting; waiting = []; w.forEach(function (f) { f(); }); }
|
|
function computeFit() {
|
|
var b = scn.bbox, sz = size(), ar = sz[0] / sz[1], w = b[2] * 1.02, h = b[3] * 1.04;
|
|
if (w / h > ar) h = w / ar; else w = h * ar;
|
|
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(), 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);
|
|
schedule();
|
|
}
|
|
function fit() { if (!scn) return; vb = computeFit(); apply(); touched = false; }
|
|
function clampVB() {
|
|
var b = scn.bbox, cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
|
|
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, 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];
|
|
if (px === undefined) { px = 0.5; py = 0.5; }
|
|
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();
|
|
if (gesture) applyLive(); else 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();
|
|
if (sz[0] < 640 && scn && !isFull()) {
|
|
var nw = fitW / 2.4, nh = nw * sz[1] / sz[0];
|
|
vb = { x: scn.focus[0] - nw / 2, y: scn.focus[1] - nh / 2, w: nw, h: nh };
|
|
clampVB(); apply();
|
|
}
|
|
touched = false;
|
|
}
|
|
function schedule() {
|
|
if (declT) return;
|
|
declT = (window.requestAnimationFrame || setTimeout)(function () { declT = 0; declutter(); });
|
|
}
|
|
function declutter() {
|
|
if (!alive || !vb) return;
|
|
var sz = size(), k = vb.w / sz[0], kept = [], i, j, t, x, y, r, vis;
|
|
var bt = bar.offsetTop + bar.offsetHeight + 2, bw = bar.offsetLeft + bar.offsetWidth;
|
|
for (i = 0; i < metas.length; i++) {
|
|
t = metas[i]; x = (t.x - vb.x) / k; y = (t.y - vb.y) / k;
|
|
r = [x - t.w / 2 - 2, y - 23, x + t.w / 2 + 2, y - 4];
|
|
vis = r[0] >= 2 && r[2] <= sz[0] - 2 && r[1] >= 2 && r[3] <= sz[1] - 2 && !(r[1] < bt && r[0] < bw);
|
|
for (j = 0; vis && j < kept.length; j++) if (!(r[2] < kept[j][0] || r[0] > kept[j][2] || r[3] < kept[j][1] || r[1] > kept[j][3])) vis = false;
|
|
if (vis) kept.push(r);
|
|
if (t.hid === vis) { t.hid = !vis; t.el.classList.toggle("iso-hide", !vis); }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------- 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) {
|
|
tipNext = { info: info, cx: cx, cy: cy };
|
|
if (!tipRaf) tipRaf = (window.requestAnimationFrame || setTimeout)(placeTip);
|
|
}
|
|
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"); });
|
|
st.sel = id || null;
|
|
if (st.selObj) selectObj(st.selObj);
|
|
if (!id) return;
|
|
Array.prototype.forEach.call(q("data-id", id), function (e) { if (!e.classList.contains("iso-tag")) e.classList.add("iso-sel"); });
|
|
}
|
|
// outline an object, or several (an array of ids)
|
|
function selectObj(id) {
|
|
Array.prototype.forEach.call(svg.querySelectorAll(".iso-osel"), function (e) { e.classList.remove("iso-osel"); });
|
|
var ids = Array.isArray(id) ? id : id ? [id] : [];
|
|
st.selObj = ids.length ? id : null;
|
|
ids.forEach(function (i) { Array.prototype.forEach.call(q("data-obj", i), function (e) { e.classList.add("iso-osel"); }); });
|
|
}
|
|
function targetId(t) { var e = t && t.closest ? t.closest("[data-id]") : null; return e && svg.contains(e) ? e.getAttribute("data-id") : null; }
|
|
function targetObj(t) { var e = t && t.closest ? t.closest("[data-obj]") : null; return e && svg.contains(e) ? e.getAttribute("data-obj") : null; }
|
|
function targetSrc(t) { var e = t && t.closest ? t.closest("[data-src]") : null; return e && svg.contains(e) ? e.getAttribute("data-src") : null; }
|
|
function pick(t, cx, cy, ev) {
|
|
var id = targetId(t), obj = targetObj(t);
|
|
if (!id && !obj) { select(null); hideTip(); call(opts.onPick, null, ev); return; }
|
|
var info = infoOf(id || obj);
|
|
if (!opts.noSelect) select(id);
|
|
if (!opts.noTip) showTip(info, cx, cy);
|
|
call(opts.onPick, { id: info.id || id, name: info.name, detail: info.detail, rows: info.rows || [], obj: obj, src: targetSrc(t) }, ev);
|
|
}
|
|
// client point -> world plan at height z
|
|
function toWorld(cx, cy, z) {
|
|
if (!vb) return null;
|
|
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; }
|
|
|
|
// ---------------------------------------------------------- pointer
|
|
var ptrs = {}, down = null, moved = false, pinch = null, hoverId = null, claimed = false;
|
|
function npt() { return Object.keys(ptrs).length; }
|
|
svg.addEventListener("pointerdown", function (e) {
|
|
// the middle (or any other non-left) mouse button only moves the camera: it never
|
|
// drags, places or selects an object, whatever is under the pointer
|
|
var cam = e.pointerType === "mouse" && e.button !== 0;
|
|
if (cam && e.button === 1) e.preventDefault();
|
|
ptrs[e.pointerId] = { x: e.clientX, y: e.clientY };
|
|
if (npt() === 1) {
|
|
down = { x: e.clientX, y: e.clientY, id: e.pointerId, t: e.target, cam: cam }; moved = false;
|
|
claimed = !cam && !!(opts.interact && call(opts.interact.down, e, { obj: targetObj(e.target), id: targetId(e.target), world: toWorld(e.clientX, e.clientY, 0) }));
|
|
if (claimed) { try { svg.setPointerCapture(e.pointerId); } catch (er) { /* ignore */ } }
|
|
}
|
|
if (npt() === 2) {
|
|
if (claimed) { call(opts.interact.cancel); claimed = false; }
|
|
var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]];
|
|
pinch = { d: Math.hypot(a.x - b.x, a.y - b.y) || 1 }; moved = true;
|
|
}
|
|
});
|
|
svg.addEventListener("pointermove", function (e) {
|
|
if (opts.onHover) call(opts.onHover, toWorld(e.clientX, e.clientY, 0), e);
|
|
var p = ptrs[e.pointerId];
|
|
if (p) {
|
|
var dx = e.clientX - p.x, dy = e.clientY - p.y;
|
|
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, 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");
|
|
if (!claimed) { try { svg.setPointerCapture(e.pointerId); } catch (er) { /* ignore */ } }
|
|
}
|
|
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, true);
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
if (e.pointerType === "mouse" && !opts.noTip) {
|
|
var id = targetId(e.target);
|
|
if (id) {
|
|
if (id !== hoverId) { if (hoverId) setHover(hoverId, false); setHover(id, true); hoverId = id; }
|
|
showTip(infoOf(id), e.clientX, e.clientY);
|
|
} else if (hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); }
|
|
}
|
|
});
|
|
function setHover(id, on) {
|
|
Array.prototype.forEach.call(q("data-id", id), function (e) { if (!e.classList.contains("iso-tag")) e.classList.toggle("iso-hov", on); });
|
|
}
|
|
function up(e) {
|
|
var wasDown = down && down.id === e.pointerId;
|
|
if (live) apply();
|
|
delete ptrs[e.pointerId];
|
|
if (npt() < 2) pinch = null;
|
|
if (wasDown) {
|
|
if (claimed) call(opts.interact.up, e, { moved: moved, world: toWorld(e.clientX, e.clientY, 0), cancel: e.type !== "pointerup" });
|
|
else if (!moved && !down.cam && e.type === "pointerup") pick(e.target, e.clientX, e.clientY, e);
|
|
}
|
|
if (npt() === 0) { down = null; claimed = false; wrap.classList.remove("iso-dragging"); }
|
|
}
|
|
svg.addEventListener("pointerup", up);
|
|
svg.addEventListener("pointercancel", up);
|
|
// no browser autoscroll or paste on a middle click
|
|
svg.addEventListener("mousedown", function (e) { if (e.button === 1) e.preventDefault(); });
|
|
svg.addEventListener("auxclick", function (e) { if (e.button === 1) e.preventDefault(); });
|
|
svg.addEventListener("pointerleave", function (e) { if (e.pointerType === "mouse" && hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); } });
|
|
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, true);
|
|
}, { passive: false });
|
|
svg.addEventListener("dblclick", function (e) {
|
|
e.preventDefault();
|
|
// an editor tool may take the double click (it finishes a drawn run)
|
|
if (opts.interact && call(opts.interact.dblclick, e)) return;
|
|
zoomClient(1.8, e.clientX, e.clientY);
|
|
});
|
|
svg.addEventListener("keydown", function (e) {
|
|
if (opts.onKey && call(opts.onKey, e)) { e.preventDefault(); return; }
|
|
var k = e.key;
|
|
if (k === "+" || k === "=") zoomAt(1.4); else if (k === "-") zoomAt(1 / 1.4); else if (k === "0") fit();
|
|
else if (k === "ArrowLeft") panPx(60, 0); else if (k === "ArrowRight") panPx(-60, 0); else if (k === "ArrowUp") panPx(0, 60); else if (k === "ArrowDown") panPx(0, -60);
|
|
else if (k === "Escape") { select(null); hideTip(); }
|
|
else if ((k === "f" || k === "F") && fsBtn && !e.ctrlKey && !e.metaKey && !e.altKey) setFull(!isFull()); else return;
|
|
e.preventDefault();
|
|
});
|
|
bar.addEventListener("click", function (e) {
|
|
var b = e.target.closest ? e.target.closest("button") : null;
|
|
if (!b) return;
|
|
var a = b.getAttribute("data-a");
|
|
if (a === "level") setLevel(b.getAttribute("data-level"));
|
|
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) {
|
|
if (!l || l === st.level || !levels.some(function (x) { return x.id === l; })) return;
|
|
st.level = l; hideTip(); build();
|
|
call(opts.onLevel, l);
|
|
}
|
|
function rotate(d) {
|
|
var keepZoom = vb ? vb.w / fitW : 1;
|
|
st.rot = rotOf(st.rot + (d || 1)); hideTip();
|
|
pendingFrame = function () {
|
|
if (keepZoom >= 0.995) { fit(); return; }
|
|
computeFit();
|
|
var sz = size(), nw = fitW * keepZoom, nh = nw * sz[1] / sz[0], c = scn.focus;
|
|
if (st.selObj && scn.boxes && scn.boxes[st.selObj]) { var b = scn.boxes[st.selObj]; c = project(st.rot, (b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2); }
|
|
vb = { x: c[0] - nw / 2, y: c[1] - nh / 2, w: nw, h: nh }; clampVB(); apply();
|
|
};
|
|
build();
|
|
call(opts.onRotate, st.rot);
|
|
}
|
|
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;
|
|
var old = lastSize; lastSize = sz;
|
|
if (!vb || !old[0] || !touched) { focusDefault(); return; }
|
|
var wasFit = atFit, k = vb.w / old[0], cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
|
|
computeFit();
|
|
if (wasFit) { fit(); return; }
|
|
var nw = Math.min(fitW, k * sz[0]), nh = nw * sz[1] / sz[0];
|
|
vb = { x: cx - nw / 2, y: cy - nh / 2, w: nw, h: nh }; clampVB(); apply();
|
|
}
|
|
if (window.ResizeObserver) { ro = new ResizeObserver(onResize); ro.observe(stage); }
|
|
else window.addEventListener("resize", onResize);
|
|
if (window.IntersectionObserver) {
|
|
io = new IntersectionObserver(function (es) { es.forEach(function (en) { wrap.classList.toggle("iso-off", !en.isIntersecting); }); });
|
|
io.observe(stage);
|
|
}
|
|
build();
|
|
|
|
var view = {
|
|
el: wrap,
|
|
svg: svg,
|
|
stage: stage,
|
|
source: source,
|
|
update: function (c) { st.cfg = Object.assign({}, st.cfg, c || {}); build(); return view; },
|
|
refresh: function () { build(); return view; },
|
|
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; },
|
|
select: function (id) { select(id); return view; },
|
|
selectObj: function (id) { selectObj(id); return view; },
|
|
// centre on an object id / data-id or a world point [x, y, z] at a zoom relative to fit
|
|
focus: function (target, f) {
|
|
if (!scn) return view;
|
|
var c = null;
|
|
if (Array.isArray(target)) c = project(st.rot, target[0], target[1], target[2] || 0);
|
|
else if (target && scn.boxes && scn.boxes[target]) { var b = scn.boxes[target]; c = project(st.rot, (b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2); }
|
|
else if (target) {
|
|
var bx = [1e9, 1e9, -1e9, -1e9];
|
|
Array.prototype.forEach.call(q("data-id", target), function (e) {
|
|
if (e.classList.contains("iso-tag")) return;
|
|
try { var b = e.getBBox(); if (b.width || b.height) { bx[0] = Math.min(bx[0], b.x); bx[1] = Math.min(bx[1], b.y); bx[2] = Math.max(bx[2], b.x + b.width); bx[3] = Math.max(bx[3], b.y + b.height); } } catch (er) { /* not rendered */ }
|
|
});
|
|
if (bx[0] < 1e9) c = [(bx[0] + bx[2]) / 2, (bx[1] + bx[3]) / 2];
|
|
}
|
|
if (!c) return view;
|
|
computeFit();
|
|
var sz = size(), nw = fitW / Math.max(1, f || 3), nh = nw * sz[1] / sz[0];
|
|
vb = { x: c[0] - nw / 2, y: c[1] - nh / 2, w: nw, h: nh }; clampVB(); apply(); touched = true;
|
|
return view;
|
|
},
|
|
zoom: function (f) { zoomAt(f || 1.5); return view; },
|
|
fit: function () { fit(); return view; },
|
|
fullscreen: function (on) { setFull(on === undefined ? !isFull() : !!on); return view; },
|
|
isFullscreen: function () { return isFull(); },
|
|
toWorld: toWorld,
|
|
screenOf: function (x, y, z) { return project(st.rot, x, y, z); },
|
|
screenPx: screenPx,
|
|
scene: function () { return scn; },
|
|
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, animation: animMode, animations: animList.length }; },
|
|
destroy: function () {
|
|
alive = false;
|
|
if (ro) ro.disconnect(); else window.removeEventListener("resize", onResize);
|
|
if (isFull()) setFull(false);
|
|
document.removeEventListener("fullscreenchange", fsSync);
|
|
document.removeEventListener("webkitfullscreenchange", fsSync);
|
|
document.removeEventListener("keydown", onFsKey);
|
|
if (io) io.disconnect();
|
|
clearInterval(animTimer);
|
|
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
|
|
}
|
|
};
|
|
return view;
|
|
}
|
|
|
|
root.IsoKit = { mount: mount, staticSource: staticSource, httpSource: httpSource, project: project, unproject: unproject, version: VERSION };
|
|
})(typeof window !== "undefined" ? window : this);
|