In Part 1, we explored the mathematics behind the panorama viewer — equirectangular projections, quaternion algebra, SLERP interpolation, and coordinate system transformations. This post covers the same ground from a code implementation perspective: how the component is structured, why each line exists, what patterns solve what problems, and where the gotchas hide.
---
interface Props {
src: string;
alt: string;
}
const { src, alt } = Astro.props;
---
Astro components run their frontmatter at build time (SSG) or request time (SSR). The props are typed with TypeScript. src is the panorama image URL, alt is accessibility text.
The props are passed to the DOM via data attributes rather than being serialized into the script:
<div class="pano-wrap" id="pano-wrap" aria-label={alt} role="img" data-src={src}>
Why data-src instead of passing directly to the script? Astro’s <script> tags are bundled and deduplicated — they don’t have access to per-instance props. The DOM serves as the bridge: the script reads wrap.dataset.src at runtime.
<canvas id="pano-canvas"></canvas>
A bare canvas — no width/height attributes. The renderer sizes it programmatically via renderer.setSize(). Setting dimensions in HTML would fight with the CSS width: 100%; height: 100% and cause blurriness.
<div class="pano-loader" id="pano-loader">
<div class="pano-spinner"></div>
<span class="pano-progress" id="pano-progress">0%</span>
<span class="pano-error" id="pano-error" hidden></span>
</div>
The loader overlay sits above the canvas (via z-index: 2). It’s hidden with display: none on success — not removed from DOM, because that would complicate error recovery paths.
<button class="pano-motion" id="pano-motion" type="button" aria-pressed="false" hidden>
The motion button starts hidden. It’s conditionally shown by JavaScript only when gyro hardware is detected. This prevents a flash of non-functional UI on desktop.
<script>
import { WebGLRenderer, Scene, ... } from "three";
// ...
</script>
Astro processes <script> tags through Vite — this import is tree-shaken at build time. Only the imported Three.js modules end up in the bundle. No client:load directive needed — Astro scripts in .astro files are always client-side.
function setupPanoramaViewer() {
const wrap = document.getElementById("pano-wrap") as HTMLElement | null;
const canvas = document.getElementById("pano-canvas");
const loader = document.getElementById("pano-loader") as HTMLElement | null;
// ...
if (!wrap || !canvas || !loader) return;
DOM queries by ID — fast, unambiguous, no risk of matching a wrong element. The null checks are an early exit pattern: if the DOM isn’t ready or the elements were removed, bail immediately.
const src = wrap.dataset.src!;
const el = wrap; // Re-bind as definitely non-null
TypeScript narrows wrap to HTMLElement after the null check, but closures inside callbacks lose this narrowing (the compiler can’t prove the variable isn’t reassigned between definition and invocation). Re-binding to const el preserves the narrowing in all nested scopes.
The ! on dataset.src is safe because the Astro template guarantees the attribute exists.
if (el.dataset.panoInitialized === "true") return;
el.dataset.panoInitialized = "true";
Problem: The setup function is called from two places:
document.addEventListener("astro:page-load", setupPanoramaViewer);
setupPanoramaViewer(); // immediate call for first page load
The immediate call handles the initial page load (where astro:page-load may have already fired). The event listener handles View Transition navigations. Without the guard, navigating away and back would create a second WebGL context on the same canvas.
Why a data attribute instead of a module-level boolean? If the user navigates away and the cleanup handler fires (removing the old DOM), the data attribute dies with the element. When the page is re-created with fresh DOM, the new element has no data-pano-initialized — init runs correctly.
{
// ── Renderer ──
let renderer: WebGLRenderer;
// ... entire viewer logic
}
The naked { } block is unusual. It creates a closure scope that:
setupPanoramaViewer scopelet/const declarations that would otherwise conflict if setupPanoramaViewer were called twice (though the guard prevents this)let renderer: WebGLRenderer;
try {
renderer = new WebGLRenderer({ canvas, antialias: false, alpha: false });
} catch (e) {
// ... error handling
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
| Option | Value | Reason |
|---|---|---|
canvas | DOM element | Reuse existing canvas (don’t create a new one) |
antialias | false | Panoramas are texture-bound, not geometry-bound. AA wastes GPU cycles smoothing edges you can’t see |
alpha | false | Opaque background. Setting false allows the browser to skip alpha compositing — measurable perf win on mobile |
The WebGLRenderer constructor calls canvas.getContext("webgl2") (or "webgl" fallback). This can throw if:
The try/catch is the only reliable way to detect this — there’s no navigator.gpu.available API for WebGL.
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
setPixelRatio controls the internal resolution of the framebuffer. On a 3× device (iPhone 14 Pro) with a 390×844 CSS-pixel viewport:
The visual difference is negligible for panoramic textures (the texture is the resolution bottleneck, not the canvas). The performance and thermal difference is significant.
const scene = new Scene();
const camera = new PerspectiveCamera(90, 1, 0.1, 1000);
camera.rotation.order = "YXZ";
fov: 90 — initial vertical field of view in degreesaspect: 1 — placeholder; immediately overwritten by resize()near: 0.1 — near clipping plane distancefar: 1000 — far clipping plane distanceThe near/far values define the depth buffer range. Our geometry is at radius 500, so we need near < 500 < far. The ratio far/near = 10000 determines depth buffer precision — more than adequate when we only have a single surface.
camera.rotation.order = "YXZ";
This is set once at initialization and affects every subsequent .rotation.set() call. If we forgot this line, Three.js defaults to "XYZ" — pitch before yaw — which causes gimbal lock at the poles and unintuitive drag behaviour.
const material = new MeshBasicMaterial({ map: null, side: BackSide });
MeshBasicMaterial — no lighting calculations. The panorama is a photograph; applying lighting would darken parts of the sphere, which makes no physical sense. Setting map: null initially lets us create the material before the texture loads; we assign it later:
material.map = tex;
material.needsUpdate = true;
needsUpdate = true forces Three.js to recompile the material’s shader program. Without it, the shader was compiled for map: null (solid color) and won’t sample the texture.
const texLoader = new TextureLoader();
const LOAD_TIMEOUT_MS = 30000;
let loadTimedOut = false;
const loadTimer = setTimeout(() => {
loadTimedOut = true;
if (progressEl) progressEl.textContent = "";
if (errorEl) {
errorEl.hidden = false;
errorEl.textContent = "Loading is taking longer than expected.";
}
}, LOAD_TIMEOUT_MS);
The timeout is non-cancelling — it doesn’t abort the fetch. It only shows a warning. This is a deliberate UX choice: a slow connection might eventually succeed, and cancelling would guarantee failure.
The loadTimedOut boolean is checked in both the error callback (to provide a specific message) and is implicitly irrelevant on success (the timer is cleared).
texLoader.load(
src,
(tex) => {
clearTimeout(loadTimer);
tex.colorSpace = SRGBColorSpace;
tex.generateMipmaps = false;
tex.minFilter = LinearFilter;
tex.wrapS = RepeatWrapping;
tex.repeat.x = -1;
Line by line:
tex.colorSpace = SRGBColorSpace — Tells Three.js the texture is in sRGB. The renderer will convert to linear space for correct lighting math (even though we use MeshBasicMaterial, this affects gamma-correct blending).
tex.generateMipmaps = false — Mipmaps are progressively downsampled copies of the texture (½, ¼, ⅛ sizes). They’re used when a texture is viewed at a distance. For a panorama:
tex.minFilter = LinearFilter — Required when generateMipmaps = false. The default LinearMipmapLinearFilter would sample non-existent mipmaps, causing a black texture on some GPUs.
tex.wrapS = RepeatWrapping — Enables the negative repeat trick. Default ClampToEdgeWrapping doesn’t allow repeat.x = -1.
tex.repeat.x = -1 — Flips the horizontal UV to counter the BackSide mirror. Without this, text in the panorama reads backwards.
const imgW = tex.image.width;
const imgH = tex.image.height;
const ratio = imgW / imgH;
let mesh: Mesh;
if (ratio > 3) {
// Cylindrical
} else {
// Spherical
}
The tex.image is the decoded HTMLImageElement. Its width/height are the natural pixel dimensions, available synchronously after load.
Why ratio > 3? An equirectangular image has ratio 2:1. Anything significantly wider than 2:1 means the image covers very little vertical angle — a sphere would waste surface. The threshold of 3 catches images like 4:1, 6:1, 9:1 strip panoramas while keeping 2.5:1 (partial photospheres with ~144° vertical coverage) on the sphere path.
const fullHeight = imgW / 2;
const thetaLength = Math.min(Math.PI, (imgH / fullHeight) * Math.PI);
const thetaStart = (Math.PI - thetaLength) / 2;
const geometry = new SphereGeometry(500, 60, 40, 0, Math.PI * 2, thetaStart, thetaLength);
mesh = new Mesh(geometry, material);
fullHeight = imgW / 2 — the height an image would have if it covered a full 180° vertically. This is the reference for proportional computation.
The Math.min(Math.PI, ...) guard prevents thetaLength from exceeding π (180°). This could happen if someone provides a nearly-square image — we cap at a full hemisphere rather than attempting impossible geometry.
const radius = 500;
const height = 2 * Math.PI * radius * (imgH / imgW);
const geometry = new CylinderGeometry(radius, radius, height, 64, 1, true);
mesh = new Mesh(geometry, material);
CylinderGeometry parameters: (radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded).
openEnded: true — no cap geometry at top/bottom. Caps would show a solid colour disc if the user managed to look past the edgescene.add(mesh);
loader.style.display = "none";
needsRender = true;
resize();
animate();
setTimeout(() => {
if (hint) hint.style.opacity = "0";
}, 3500);
Order matters:
scene.add(mesh) — mesh must be in scene before first renderloader.style.display = "none" — hide overlay, reveal canvasneedsRender = true — flag first frameresize() — set correct canvas dimensions (may have changed during load)animate() — start the render loop (called once; it self-chains via rAF)(progressEvent) => {
if (progressEvent.lengthComputable && progressEvent.total > 0) {
const pct = Math.round((progressEvent.loaded / progressEvent.total) * 100);
if (progressEl) progressEl.textContent = `${pct}%`;
} else if (progressEvent.loaded > 0) {
const mb = (progressEvent.loaded / (1024 * 1024)).toFixed(1);
if (progressEl) progressEl.textContent = `${mb} MB`;
}
}
Two display modes:
Content-Length headerContent-Length (common with CDNs that use chunked transfer encoding)The progressEvent.total > 0 guard catches the case where lengthComputable is true but total is 0 (observed on some proxy servers).
let lon = 0;
let lat = 0;
let fov = 90;
let LAT_MAX = 85;
const FOV_MIN = 30;
const FOV_MAX = 110;
let needsRender = false;
let manualViewDirty = false;
let paused = false;
let destroyed = false;
const DEG2RAD = Math.PI / 180;
lon, lat, fov — continuously modified by input handlersLAT_MAX — modified once (at geometry creation for cylinders), then stableFOV_MIN/MAX — truly constant, never changeneedsRender — flag, toggled every framemanualViewDirty — synchronization flag between Euler and quaternion pathsdestroyed — one-way latch (false → true, never back)DEG2RAD — precomputed constant (avoids repeated division in hot paths)needsRender FlagThis is the render-on-demand gate. Any code path that changes visual state must set it:
// Zoom changed:
needsRender = true;
// Resize:
needsRender = true;
// Orientation event received:
needsRender = true;
The flag is consumed in the animation loop:
if (moved || needsRender) {
renderer.render(scene, camera);
needsRender = false;
}
Why not just always render? A panorama viewer spends most of its time idle. On a mobile device, continuous 60fps rendering:
manualViewDirty FlagThis flag distinguishes between “camera was moved via lon/lat” and “camera was moved via quaternion SLERP”:
// Drag handler:
lon += velocityX;
manualViewDirty = true;
// Animation loop:
if (manualViewDirty) {
camera.rotation.set(lat * DEG2RAD, -lon * DEG2RAD, 0, "YXZ");
manualViewDirty = false;
}
If we always called camera.rotation.set(...), it would overwrite the quaternion that SLERP just set — the two control paths would fight.
el.addEventListener("pointerdown", (e) => {
if (activePointers.size === 0) {
didDrag = false;
didPinch = false;
}
activePointers.add(e.pointerId);
pointerX = e.clientX;
pointerY = e.clientY;
velocityX = 0;
velocityY = 0;
el.setPointerCapture(e.pointerId);
el.style.cursor = "grabbing";
});
activePointers.size === 0 check — only reset drag/pinch flags on the first finger down. A second finger shouldn’t clear the didDrag flag that records whether we already started dragging.
el.setPointerCapture(e.pointerId) — critical. Without capture, moving the pointer outside the element boundary would stop receiving events. With capture, the element receives all pointer events until release, regardless of cursor position. This prevents “stuck drag” when the user moves fast.
velocityX = 0; velocityY = 0; — kill any ongoing momentum immediately. If the user taps during a spin, the panorama should stop dead.
el.addEventListener("pointermove", (e) => {
if (activePointers.size === 0 || isPinching) return;
const fovScale = fov / 90;
const dx = (e.clientX - pointerX) * DRAG_SENSITIVITY * fovScale;
const dy = (e.clientY - pointerY) * DRAG_SENSITIVITY * fovScale;
velocityX = -dx;
velocityY = dy;
didDrag = true;
lon += velocityX;
lat += velocityY;
lat = Math.max(-LAT_MAX, Math.min(LAT_MAX, lat));
pointerX = e.clientX;
pointerY = e.clientY;
manualViewDirty = true;
needsRender = true;
});
Guard: activePointers.size === 0 — ignore stray move events when no button is pressed (happens on touch devices that fire pointermove for hover).
Guard: isPinching — if two fingers are down, don’t interpret movement as a drag.
velocityX = -dx — we store the per-frame displacement as velocity. When the user releases, this last value becomes the initial momentum. The negation makes “drag right” = “scene moves right” = “camera turns left”.
lat clamping inline — applied immediately, not deferred. If we deferred, the unclamped value could accumulate during rapid swipes, causing a snapping effect when rendering catches up.
pointerX = e.clientX — update reference point after computing delta. If we updated first, dx would always be 0.
el.addEventListener("pointerup", (e) => {
activePointers.delete(e.pointerId);
if (activePointers.size === 0) {
if (motionEnabled && didDrag && !didPinch) resetMotionOrigin();
el.style.cursor = "grab";
}
});
motionEnabled && didDrag && !didPinch — recalibrate gyro ONLY if:
The didPinch check prevents double-recalibration: pinch-end already calls resetMotionOrigin().
el.addEventListener("pointercancel", (e) => {
activePointers.delete(e.pointerId);
if (activePointers.size === 0) {
velocityX = 0;
velocityY = 0;
el.style.cursor = "grab";
}
});
pointercancel fires when the browser decides to take over the gesture (e.g., system gesture, palm rejection on tablets). We kill momentum entirely — it’s unsafe to assume the last velocity was intentional.
el.addEventListener("wheel", (e) => {
e.preventDefault();
fov += e.deltaY * 0.05;
fov = Math.max(FOV_MIN, Math.min(FOV_MAX, fov));
camera.fov = fov;
camera.updateProjectionMatrix();
needsRender = true;
}, { passive: false });
e.preventDefault() — prevents page scroll. Without this, scrolling over the panorama would scroll the page behind it.
{ passive: false } — required to call preventDefault(). Passive listeners (the modern default for touch/wheel) cannot prevent the default action. Explicitly declaring non-passive tells the browser to wait for our handler before deciding whether to scroll.
camera.updateProjectionMatrix() — must be called after changing fov. Three.js caches the projection matrix; changing the property alone does nothing until you rebuild the matrix.
let lastPinchDist = 0;
el.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) {
isPinching = true;
didPinch = true;
velocityX = 0;
velocityY = 0;
cachedScreenAngle = screenAngle();
lastPinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
}
}, { passive: true });
Why touchstart/touchmove/touchend alongside pointer events? Pinch is a two-finger gesture that doesn’t map cleanly to the pointer event model. With pointer events, you’d get two independent pointermove streams and have to manually compute the inter-finger distance. Touch events give you both fingers in one event (e.touches), making pinch detection trivial.
cachedScreenAngle = screenAngle() — lock the screen orientation reading. If the user starts a pinch and inadvertently crosses the portrait/landscape threshold, we don’t want the gyro’s screen correction to suddenly jump 90°.
{ passive: true } — touchstart doesn’t need preventDefault() (we don’t want to prevent scrolling from starting, we just want to detect pinch).
el.addEventListener("touchmove", (e) => {
if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const delta = lastPinchDist - dist;
fov += delta * 0.1;
fov = Math.max(FOV_MIN, Math.min(FOV_MAX, fov));
camera.fov = fov;
camera.updateProjectionMatrix();
lastPinchDist = dist;
needsRender = true;
e.preventDefault();
}
}, { passive: false });
lastPinchDist - dist — positive when fingers move together (pinch in = zoom out). The sign convention matches the wheel: positive delta = zoom out (larger FOV).
e.preventDefault() here prevents the browser from interpreting the two-finger gesture as a page zoom (on browsers that support pinch-to-zoom). This requires { passive: false }.
el.addEventListener("touchend", (e) => {
if (e.touches.length < 2 && isPinching) {
isPinching = false;
cachedScreenAngle = null;
if (motionEnabled) resetMotionOrigin();
if (e.touches.length === 1) {
pointerX = e.touches[0].clientX;
pointerY = e.touches[0].clientY;
}
}
}, { passive: true });
e.touches.length < 2 && isPinching — only trigger transition when dropping FROM pinch. Without the isPinching check, every single-finger touchend would run this code.
cachedScreenAngle = null — unlock. Future orientation events will read the live value.
Remaining finger sync:
if (e.touches.length === 1) {
pointerX = e.touches[0].clientX;
pointerY = e.touches[0].clientY;
}
This is the “finger transition” fix. After pinch ends with one finger still down, the pointermove handler will resume single-finger drag. Without this sync, the first pointermove would compute dx = currentX - pointerX where pointerX was set during the initial pointerdown (possibly hundreds of pixels away), causing a violent jump.
let lastTapTime = 0;
el.addEventListener("touchend", (e) => {
if (e.touches.length !== 0 || isPinching) return;
const now = Date.now();
if (now - lastTapTime < 300) {
fov = 90;
camera.fov = fov;
camera.updateProjectionMatrix();
needsRender = true;
lastTapTime = 0; // prevent triple-tap from triggering again
} else {
lastTapTime = now;
}
}, { passive: true });
e.touches.length !== 0 — only count when ALL fingers are up (full release).
isPinching guard — don’t count the end of a pinch as a potential double-tap.
lastTapTime = 0 after trigger — resets the detector so a third quick tap doesn’t re-trigger.
const KEYS: Record<string, boolean> = {};
const onKeyDown = (e: KeyboardEvent) => { KEYS[e.key] = true; needsRender = true; };
const onKeyUp = (e: KeyboardEvent) => { KEYS[e.key] = false; };
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
Direct handling would apply rotation once per keydown event. But keydown repeats at the OS key-repeat rate (~30Hz on Windows, ~15Hz initially on macOS), which produces jerky rotation.
The key map pattern defers to the animation loop:
// In animate():
if (KEYS["ArrowLeft"] || KEYS["a"] || KEYS["A"]) {
lon -= 0.35;
manualViewDirty = true;
moved = true;
}
This applies rotation at the display refresh rate (60Hz), producing smooth motion independent of key-repeat settings.
The map naturally supports diagonal movement:
ArrowLeft + ArrowUp → both lon -= 0.35 and lat += 0.35 per frame+/-)window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
These are on window (not el) so they work regardless of focus. The cleanup handler removes them:
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
Named function references are stored specifically for removal — anonymous arrow functions can’t be removed.
type DeviceOrientationEventWithPermission = typeof DeviceOrientationEvent & {
requestPermission?: () => Promise<"granted" | "denied" | "default">;
};
iOS 13+ added requestPermission() as a static method on DeviceOrientationEvent. The TypeScript DOM types don’t include it. This augmentation adds it optionally — the ? allows safe checking with MotionEvent?.requestPermission.
let motionEnabled = false;
let hasMotionTarget = false;
const MOTION_SMOOTHING = 0.18;
const motionEuler = new Euler(0, 0, 0, "YXZ");
const motionTargetQuaternion = new Quaternion();
const motionDeviceQuaternion = new Quaternion();
const motionCorrectionQuaternion = new Quaternion();
const motionScreenQuaternion = new Quaternion();
const motionZAxis = new Vector3(0, 0, 1);
const motionCameraAdjustment = new Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5));
Why pre-allocate objects? The onDeviceOrientation handler fires 60+ times per second. Creating new Quaternion() each time would generate garbage, triggering GC pauses that cause visible frame drops on mobile.
All quaternion/euler/vector objects are allocated once and reused via .copy(), .set(), .multiply() (mutating methods).
motionCameraAdjustment — pre-computed constant quaternion for the -90° X rotation. Never changes.
function screenAngle() {
if (cachedScreenAngle !== null) return cachedScreenAngle;
if (typeof screen.orientation?.angle === "number") return screen.orientation.angle;
const legacyWindow = window as Window & { orientation?: number };
return typeof legacyWindow.orientation === "number" ? legacyWindow.orientation : 0;
}
Three fallback levels:
screen.orientation.angle — modern API (Chrome, Firefox)window.orientation — deprecated iOS API (still needed on some WebViews)0 — default to portrait if nothing worksThe as Window & { orientation?: number } cast is needed because TypeScript’s strict DOM types removed window.orientation.
function setQuaternionFromDeviceOrientation(target: Quaternion, e: DeviceOrientationEvent) {
const alpha = (e.alpha ?? 0) * DEG2RAD;
const beta = (e.beta ?? 0) * DEG2RAD;
const gamma = (e.gamma ?? 0) * DEG2RAD;
const orient = screenAngle() * DEG2RAD;
motionEuler.set(beta, alpha, -gamma, "YXZ");
target.setFromEuler(motionEuler);
target.multiply(motionCameraAdjustment);
target.multiply(motionScreenQuaternion.setFromAxisAngle(motionZAxis, -orient));
}
e.alpha ?? 0 — null coalescing. If the magnetometer hasn’t initialized, alpha is null. We default to 0 (north) rather than crashing.
motionEuler.set(beta, alpha, -gamma, "YXZ") — the argument order is (x, y, z, order). We place:
beta (front-back tilt) in X positionalpha (compass heading) in Y position-gamma (left-right tilt, negated) in Z positionThis remapping is the W3C spec’s intrinsic ZXY → extrinsic YXZ conversion.
Three multiplications in sequence:
target = Euler(device angles) × CameraAdjust(-90°X) × ScreenRotation(-orient°Z)
Quaternion multiplication is right-to-left in terms of rotation composition. The rightmost is applied first in world frame. So:
function syncLonLatFromCamera() {
motionEuler.setFromQuaternion(camera.quaternion, "YXZ");
lon = -motionEuler.y / DEG2RAD;
lat = Math.max(-LAT_MAX, Math.min(LAT_MAX, motionEuler.x / DEG2RAD));
}
After SLERP modifies the camera quaternion, we decompose it back to Euler to keep lon/lat synchronized. The negation on lon reverses the same sign flip used in camera.rotation.set(lat, -lon, 0).
The lat clamping here is a safety net — SLERP can produce values slightly beyond LAT_MAX during transitions. Without clamping, a subsequent manual drag would start from an out-of-bounds position.
function onDeviceOrientation(e: DeviceOrientationEvent) {
if (!motionEnabled || e.alpha === null || e.beta === null || e.gamma === null) return;
if (isPinching) return;
setQuaternionFromDeviceOrientation(motionDeviceQuaternion, e);
if (!hasMotionTarget) {
motionCorrectionQuaternion
.copy(camera.quaternion)
.multiply(motionDeviceQuaternion.clone().invert());
}
motionTargetQuaternion
.copy(motionCorrectionQuaternion)
.multiply(motionDeviceQuaternion);
hasMotionTarget = true;
needsRender = true;
}
Guard 1: !motionEnabled — if user toggled off, ignore events (listener is removed, but belt-and-suspenders).
Guard 2: null angles — sensor not ready. Proceeding would produce NaN quaternions.
Guard 3: isPinching — pinch suppression (discussed in synchronization section).
Calibration branch (!hasMotionTarget):
motionCorrectionQuaternion
.copy(camera.quaternion) // start with current camera orientation
.multiply(motionDeviceQuaternion.clone().invert()); // subtract current device orientation
Note .clone().invert() — we must clone before inverting because .invert() is a mutating operation. If we inverted motionDeviceQuaternion directly, we’d corrupt it for the next line.
Target computation:
motionTargetQuaternion
.copy(motionCorrectionQuaternion) // apply the constant bias
.multiply(motionDeviceQuaternion); // then the live device rotation
This runs every frame (at sensor rate). The correction quaternion stays fixed between recalibrations.
const onMotionButtonClick = async () => {
if (!motionButton) return;
if (motionEnabled) {
// Turning OFF
motionEnabled = false;
window.removeEventListener("deviceorientation", onDeviceOrientation, true);
resetMotionOrigin();
setMotionButtonState();
return;
}
// Turning ON
const MotionEvent = window.DeviceOrientationEvent as DeviceOrientationEventWithPermission | undefined;
if (MotionEvent?.requestPermission) {
const permission = await MotionEvent.requestPermission();
if (permission !== "granted") return;
}
motionEnabled = true;
resetMotionOrigin();
window.addEventListener("deviceorientation", onDeviceOrientation, true);
setMotionButtonState();
};
async — needed for iOS permission request which returns a Promise.
Turn-off path:
Turn-on path:
?. check skips this on Android/desktop)true (capture phase — receives events before any child handlers could stopPropagation)if (motionButton && "DeviceOrientationEvent" in window && window.matchMedia("(pointer: coarse)").matches) {
motionButton.hidden = false;
motionButton.addEventListener("click", onMotionButtonClick);
}
Three conditions:
"DeviceOrientationEvent" in window)pointer: coarse)Why check pointer: coarse? Desktop Chrome and Firefox expose DeviceOrientationEvent in the API but have no physical gyroscope. Showing a motion button that does nothing confuses users. Touchscreen = likely mobile = likely has gyro.
This is a heuristic — it could false-positive (touch-screen laptop without gyro) or false-negative (desktop with external sensor). For a blog panorama viewer, it’s good enough.
function animate() {
if (destroyed) return;
requestAnimationFrame(animate);
if (paused) return;
let moved = false;
// ── Momentum ──
if (activePointers.size === 0) {
if (Math.abs(velocityX) > MIN_VELOCITY || Math.abs(velocityY) > MIN_VELOCITY) {
lon += velocityX;
lat += velocityY;
lat = Math.max(-LAT_MAX, Math.min(LAT_MAX, lat));
velocityX *= FRICTION;
velocityY *= FRICTION;
manualViewDirty = true;
moved = true;
}
}
// ── SLERP ──
if (motionEnabled && activePointers.size === 0 && !isPinching && hasMotionTarget) {
if (1 - Math.abs(camera.quaternion.dot(motionTargetQuaternion)) > 0.000001) {
camera.quaternion.slerp(motionTargetQuaternion, MOTION_SMOOTHING);
syncLonLatFromCamera();
moved = true;
}
}
// ── Keyboard ──
if (KEYS["ArrowLeft"] || KEYS["a"] || KEYS["A"]) { lon -= 0.35; manualViewDirty = true; moved = true; }
if (KEYS["ArrowRight"] || KEYS["d"] || KEYS["D"]) { lon += 0.35; manualViewDirty = true; moved = true; }
if (KEYS["ArrowUp"] || KEYS["w"] || KEYS["W"]) { lat = Math.min(LAT_MAX, lat + 0.35); manualViewDirty = true; moved = true; }
if (KEYS["ArrowDown"] || KEYS["s"] || KEYS["S"]) { lat = Math.max(-LAT_MAX, lat - 0.35); manualViewDirty = true; moved = true; }
if (KEYS["+"] || KEYS["="]) { fov = Math.max(FOV_MIN, fov - 0.5); camera.fov = fov; camera.updateProjectionMatrix(); moved = true; }
if (KEYS["-"]) { fov = Math.min(FOV_MAX, fov + 0.5); camera.fov = fov; camera.updateProjectionMatrix(); moved = true; }
// ── Render ──
if (moved || needsRender) {
if (manualViewDirty) {
camera.rotation.set(lat * DEG2RAD, -lon * DEG2RAD, 0, "YXZ");
manualViewDirty = false;
}
renderer.render(scene, camera);
needsRender = false;
}
}
if (destroyed) return; — first check. Don’t call requestAnimationFrame if destroyed — this is the exit mechanism. The frame chain breaks here.
requestAnimationFrame(animate); — schedule next frame BEFORE doing work. This ensures even if processing takes >16ms, the next frame is already queued. Placing it after processing could cause frame drops.
if (paused) return; — after scheduling next frame. We still want the frame chain running (so it resumes when un-paused), but we skip all computation.
This order is deliberate:
lon/lat and sets manualViewDirty. If SLERP runs after, it will override via quaternion.camera.quaternion and calls syncLonLatFromCamera(), which writes to lon/lat (potentially overriding momentum values). The guards (activePointers.size === 0) ensure only one of momentum/SLERP runs at a time.manualViewDirty.manualViewDirty) or uses the quaternion already set by SLERP.if (1 - Math.abs(camera.quaternion.dot(motionTargetQuaternion)) > 0.000001) {
quaternion.dot(target) returns the 4D inner product — a cosine of the half-angle between them. When they’re identical, the dot product is ±1 (the sign doesn’t matter — q and -q represent the same rotation). The Math.abs() handles the double-cover.
1 - |dot| is essentially a distance metric. When it drops below 0.000001 (~0.16°), we consider the camera “arrived” and skip the SLERP. This prevents:
lon -= 0.35; // degrees per frame
At 60fps: 0.35 × 60 = 21°/sec. A full 360° rotation takes ~17 seconds — deliberate and controllable.
fov = Math.max(FOV_MIN, fov - 0.5); // zoom in 0.5° per frame
At 60fps: 0.5 × 60 = 30°/sec. From max FOV (110°) to min (30°) takes ~2.7 seconds.
function resize() {
const w = el.clientWidth;
const h = el.clientHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
needsRender = true;
}
const ro = new ResizeObserver(resize);
ro.observe(el);
window.resize?window.resize only fires when the browser window changes size. It misses:
window)ResizeObserver watches the element itself — any size change triggers the callback.
renderer.setSize(w, h) — resizes the canvas element AND the internal framebuffercamera.aspect = w / h — updates the projection matrix’s aspect ratiocamera.updateProjectionMatrix() — recomputes the matrix with new aspectneedsRender = true — schedule a re-render with the new dimensionsOrder matters: if we rendered before updating the projection matrix, one frame would show stretched content.
const onVisChange = () => {
paused = document.hidden;
if (!paused) needsRender = true;
};
document.addEventListener("visibilitychange", onVisChange);
When the tab becomes hidden, paused = true causes the animation loop to skip all processing. When revealed, we trigger one render to show the current state (in case the device moved while paused, the next SLERP cycle will smoothly correct it).
const cleanup = () => {
destroyed = true;
ro.disconnect();
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
document.removeEventListener("visibilitychange", onVisChange);
motionButton?.removeEventListener("click", onMotionButtonClick);
window.removeEventListener("deviceorientation", onDeviceOrientation, true);
scene.traverse((obj) => {
if (obj instanceof Mesh) {
obj.geometry.dispose();
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
}
});
renderer.dispose();
};
document.addEventListener("astro:before-swap", cleanup, { once: true });
{ once: true } — auto-removes after firing. Prevents the cleanup from running twice if the event somehow fires again.
Resource disposal order:
destroyed = true — stops animation loop on next framero.disconnect() — stop resize callbacks (they’d fail without a renderer)scene.traverse → dispose geometry + textures + material — frees GPU memory (VRAM)renderer.dispose() — releases the WebGL context itselfWhy dispose in this order? If we disposed the renderer first, the traverse could trigger internal Three.js operations that reference the dead context. Geometry/texture disposal is safe without an active renderer — it’s just freeing buffer handles.
.pano-wrap {
touch-action: none;
}
This tells the browser: “don’t handle any touch gestures (scroll, pinch-zoom, swipe navigation) on this element — I will handle everything myself.” Without this:
.pano-wrap {
position: absolute;
inset: 0;
}
inset: 0 is shorthand for top: 0; right: 0; bottom: 0; left: 0. Combined with position: absolute, it stretches the element to fill its positioned parent. The canvas inside then fills this with width: 100%; height: 100%.
.pano-loader {
pointer-events: none;
}
The loader overlay sits above the canvas visually (z-index: 2) but must not block pointer events from reaching the canvas beneath. Even while loading, the user might try to interact (or the loading completes and they should be able to immediately drag).
1. Browser loads page → script executes
2. setupPanoramaViewer() called immediately
3. DOM elements queried by ID
4. Initialization guard checked
5. WebGLRenderer created (may throw → error path)
6. Scene + Camera + Material created
7. TextureLoader.load(src) called → async fetch begins
8. 30s timeout timer started
9. [Waiting for image...]
├── Progress events update UI
├── Timeout fires? → show warning (load continues)
└── Error? → show message, exit
10. Image loaded successfully:
a. Texture configured (colorspace, mipmaps, UV flip)
b. Aspect ratio computed
c. Geometry created (sphere or cylinder)
d. Mesh added to scene
e. Vertical clamp + FOV adjusted (cylinder only)
f. Loader hidden
g. resize() called
h. animate() called → loop starts
i. Hint fadeout scheduled (3.5s)
11. Event listeners active:
├── pointerdown/move/up/cancel on element
├── wheel on element (passive: false)
├── touchstart/move/end on element
├── keydown/keyup on window
├── ResizeObserver on element
├── visibilitychange on document
├── deviceorientation on window (if motion toggled on)
└── astro:before-swap on document (cleanup)
12. Animation loop running:
└── Each frame: momentum → slerp → keyboard → render (if dirty)
This sequence shows why the component is structured as a single large closure — all state is captured in the same lexical scope, all handlers share the same variables, and cleanup can access everything for disposal.
Presentation
A code-focused walkthrough of the PanoramaViewer Astro component — WebGL setup, geometry construction, event wiring, quaternion gyro tracking, synchronization guards, and failure recovery patterns.
June 24, 2026