isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs
This commit is contained in:
1 parent
95c80ec6b1
commit
564aeb75f6
47 files changed
+5978
-366
No files matched your search
@@ -0,0 +1,472 @@
|
||||
/* 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) 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()
|
||||
*/
|
||||
(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>' +
|
||||
(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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- build
|
||||
function size() { var r = stage.getBoundingClientRect(); 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;
|
||||
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(); } }
|
||||
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 };
|
||||
}
|
||||
function apply() {
|
||||
if (!vb) return;
|
||||
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));
|
||||
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) {
|
||||
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();
|
||||
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 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: "" }; }
|
||||
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";
|
||||
}
|
||||
function hideTip() { 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"); });
|
||||
}
|
||||
function selectObj(id) {
|
||||
Array.prototype.forEach.call(svg.querySelectorAll(".iso-osel"), function (e) { e.classList.remove("iso-osel"); });
|
||||
st.selObj = id || null;
|
||||
if (id) Array.prototype.forEach.call(q("data-obj", id), 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) {
|
||||
var id = targetId(t), obj = targetObj(t);
|
||||
if (!id && !obj) { select(null); hideTip(); call(opts.onPick, null); 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) });
|
||||
}
|
||||
// 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;
|
||||
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) {
|
||||
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 }; moved = false;
|
||||
claimed = !!(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); 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);
|
||||
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;
|
||||
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 && e.type === "pointerup") pick(e.target, e.clientX, e.clientY);
|
||||
}
|
||||
if (npt() === 0) { down = null; claimed = false; wrap.classList.remove("iso-dragging"); }
|
||||
}
|
||||
svg.addEventListener("pointerup", up);
|
||||
svg.addEventListener("pointercancel", up);
|
||||
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);
|
||||
}, { passive: false });
|
||||
svg.addEventListener("dblclick", function (e) { e.preventDefault(); 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());
|
||||
});
|
||||
|
||||
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() {
|
||||
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; },
|
||||
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 }; },
|
||||
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();
|
||||
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);
|
||||
Reference in new issue
Block a user