Files
isokit/viewer/isokit-view.js
T
grabowski e4017ce2a8
Deploy web Studio / deploy (push) Successful in 3m52s
Fences join like walls, fence designs, draw runs corner by corner
Joints: line_joint (util.rhai) applies the house-wall rules to fences and
site walls, so pieces never overlap and the depth sort stays clean.
- A corner is closed: the thicker piece (or, if equal, the one along
  world x) runs through, and the other stops at its face.
- On a straight run, two pieces share one post.
- At a T, the stem stops at the other piece's face without a post of its
  own.
- A fence that ends at a site or house wall stops at the wall's face.

Fence designs (prop style): mesh, wooden boards, steel bars, mesh on a
stone base, cobble wall, and brick wall with pillars. Board, stone and
brick joints are drawn on the face turned to the viewer. Brick pillars
are separate sprites.

Studio: line prefabs (walls, fences, hedges, roads) can be drawn as a
run by clicking corner after corner. Double-click or Enter ends the run,
clicking the first corner closes it, and Backspace drops the last
corner. A run is one undo step. A drag still draws one piece. Corners
and ends snap onto the ends and lines of the pieces they meet, and a
marker shows where the next corner will snap.

The house demo gets a brick front wall with gate pillars and wooden side
fences in place of the old east fence, which ran over the road. Tests
cover every design's corners, Ts, straight runs and wall stops, with no
depth-sort cycles in any rotation, plus site walls. check_studio draws
and closes a fence run, undoes it in one step, snaps a dragged spur,
and picks a design.
2026-10-24 18:07:40 +07:00

485 lines
27 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) 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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">&#x21BA;</button>' +
'<button type="button" data-a="rotr" title="Rotate right" aria-label="Rotate the view right">&#x21BB;</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">&#x2212;</button>' +
'<button type="button" data-a="zin" title="Zoom in" aria-label="Zoom in">+</button>' +
'<button type="button" data-a="fit" title="Show everything" aria-label="Fit the view">Fit</button>' +
(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) {
// 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); 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 && !down.cam && 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);
// 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);
}, { 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());
});
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);