import { cameraBasis, clamp, clampZoom, dot } from "./math.js"; export const axes = [ { name: "X", vector: [1, 0, 0], yaw: Math.PI / 2, pitch: 0, color: "#ed5265", }, { name: "Y", vector: [0, 1, 0], yaw: 0, pitch: Math.PI / 2, color: "#91be38", }, { name: "Z", vector: [0, 0, 1], yaw: 0, pitch: 0, color: "#438cdd" }, { name: "−X", vector: [-1, 0, 0], yaw: -Math.PI / 2, pitch: 0, color: "#ed5265", }, { name: "−Y", vector: [0, -1, 0], yaw: 0, pitch: -Math.PI / 2, color: "#91be38", }, { name: "−Z", vector: [0, 0, -1], yaw: Math.PI, pitch: 0, color: "#438cdd" }, ]; export function dragView(camera, start, action, dx, dy, rect) { if (action === "orbit") { camera.yaw = start.yaw - dx * 0.008; camera.pitch = clamp(start.pitch + dy * 0.008, -Math.PI / 2, Math.PI / 2); } else if (action === "pan") { const { right, up } = cameraBasis(start.yaw, start.pitch); const scale = (2 * start.zoom) / rect.height; camera.target = start.target.map( (v, k) => v + (-dx * right[k] + dy * up[k]) * scale, ); } else { camera.zoom = clampZoom(start.zoom * Math.exp(dy * 0.008), rect); } } // DOM-only navigation; the camera remains owned by the app, never by the gizmo. export class Navigation { constructor(root, viewport, host) { this.root = root; this.viewport = viewport; this.host = host; this.gizmo = root.querySelector("#orbit-gizmo"); this.lines = root.querySelectorAll(".axis-line"); this.buttons = axes.map((axis, i) => { const button = document.createElement("button"); button.className = "axis"; button.type = "button"; button.dataset.axis = i; button.setAttribute("aria-label", `View from ${axis.name}`); button.title = `View from ${axis.name}`; button.textContent = i < 3 ? axis.name : ""; button.style.setProperty("--axis-color", axis.color); button.addEventListener("click", (e) => { if (e.detail === 0 && !this.busy && host.ready()) this.snap(i); }); this.gizmo.append(button); return button; }); root.addEventListener("pointerdown", (e) => this.down(e)); root.addEventListener("pointermove", (e) => this.move(e)); root.addEventListener("pointerup", (e) => this.up(e)); for (const event of ["pointercancel", "lostpointercapture"]) root.addEventListener(event, (e) => { if (this.active?.id === e.pointerId) this.cancel(); }); root.addEventListener("contextmenu", (e) => e.preventDefault()); root.addEventListener("keydown", (e) => this.key(e)); window.addEventListener("blur", () => this.cancel()); window.addEventListener("resize", () => this.checkViewport()); document.addEventListener("visibilitychange", () => { if (document.hidden) this.cancel(); }); this.render(); } get busy() { return !!this.active; } down(e) { const control = e.target.closest("[data-nav]"); if (!control || this.busy || !this.host.ready() || e.button !== 0) return; e.preventDefault(); this.host.start(); const c = this.host.camera; this.active = { id: e.pointerId, x: e.clientX, y: e.clientY, action: control.dataset.nav, axis: e.target.closest("[data-axis]")?.dataset.axis, moved: false, rect: this.viewport.getBoundingClientRect(), start: { yaw: c.yaw, pitch: c.pitch, zoom: c.zoom, target: c.target.slice(), }, }; this.root.setPointerCapture(e.pointerId); control.focus({ preventScroll: true }); this.host.changed(); } checkViewport() { if (!this.active) return true; const rect = this.viewport.getBoundingClientRect(); if ( ["left", "top", "width", "height"].some( (k) => rect[k] !== this.active.rect[k], ) ) { this.cancel(); return false; } return true; } move(e) { if (this.active?.id !== e.pointerId || !this.checkViewport()) return; const a = this.active, dx = e.clientX - a.x, dy = e.clientY - a.y; a.moved ||= Math.hypot(dx, dy) >= 4; if (a.moved) { dragView(this.host.camera, a.start, a.action, dx, dy, a.rect); this.host.invalidate(); } } up(e) { if (this.active?.id !== e.pointerId) return; this.move(e); const a = this.active; if (!a) return; this.active = null; if (this.root.hasPointerCapture(a.id)) this.root.releasePointerCapture(a.id); if (!a.moved && a.axis !== undefined) this.snap(Number(a.axis)); this.host.changed(); } cancel() { const a = this.active; if (!a) return; this.active = null; Object.assign(this.host.camera, a.start); if (this.root.hasPointerCapture(a.id)) this.root.releasePointerCapture(a.id); this.host.invalidate(); this.host.changed(); } snap(i) { // Clicking the already-facing endpoint flips to the opposite view, making // the occluded endpoint reachable when two axes project to the same point. if ( dot( cameraBasis(this.host.camera.yaw, this.host.camera.pitch).forward, axes[i].vector, ) > 0.999999 ) i = (i + 3) % 6; const { yaw, pitch } = axes[i]; Object.assign(this.host.camera, { yaw, pitch }); this.host.invalidate(); } key(e) { if (e.key === "Escape") { if (!this.busy) return; this.cancel(); e.preventDefault(); e.stopPropagation(); return; } const action = e.target.closest("[data-nav]")?.dataset.nav; if ( !action || ![ "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", ].includes(e.key) ) return; e.preventDefault(); e.stopPropagation(); if (this.busy || !this.host.ready()) return; const dx = e.key === "ArrowLeft" ? -12 : e.key === "ArrowRight" ? 12 : 0; const dy = ["ArrowUp", "+", "="].includes(e.key) ? -12 : ["ArrowDown", "-"].includes(e.key) ? 12 : 0; const c = this.host.camera; dragView( c, { ...c, target: c.target.slice() }, action, dx, dy, this.viewport.getBoundingClientRect(), ); this.host.invalidate(); } render() { const basis = cameraBasis(this.host.camera.yaw, this.host.camera.pitch); axes.forEach((axis, i) => { const x = 52 + 34 * dot(axis.vector, basis.right), y = 52 - 34 * dot(axis.vector, basis.up), depth = dot(axis.vector, basis.forward); const b = this.buttons[i]; b.style.left = `${x}px`; b.style.top = `${y}px`; b.style.zIndex = String(10 + Math.round(depth * 5)); b.classList.toggle("rear", i >= 3); if (i < 3) { this.lines[i].setAttribute("x2", x); this.lines[i].setAttribute("y2", y); this.lines[i].style.stroke = axis.color; } }); } }