main.tstypescript
import { Quaternion } from "quaternion";
import { tryInjectMockContainer } from "./mock-tool";
import type { RotationListenerConfig, RotationUpdate } from "./types";
import { quaternionFromOrientationEvent } from "./utils/conversion";
import {
permissionFunction,
requestPermission,
} from "./utils/permission-handling";
import { applyQuaternionOffset, getOffsetQuaternion } from "./utils/rotation";
let rotationOffset: Quaternion | null = null;
let hasPermission = false;
let lastUpdate = 0;
let cb: (update: RotationUpdate) => void = () => {};
let cfg: RotationListenerConfig = {
startOnInit: true,
pollingRate: 60,
onPermissionDenied: () => {},
onPermissionGranted: () => {},
onStart: () => {},
};
/**
* Updates the global configuration for the rotation listener.
*
* It is recommended to call this before subscribing with {@link onUpdate}
* or calling {@link start}.
*
* @param config - Partial configuration options to merge into the active configuration.
*
* @example
* ```typescript
* import { deviceRotation } from "web-orientation";
*
* deviceRotation.setConfig({
* pollingRate: 30, // Limit updates to 30 FPS
* startOnInit: false,
* onStart: () => console.log("Started tracking orientation"),
* onPermissionDenied: () => alert("Sensor permission denied"),
* });
* ```
*/
export const setConfig = (config: Partial<RotationListenerConfig>) => {
cfg = { ...cfg, ...config };
};
/**
* Registers a callback function to receive calibrated rotation updates.
*
* By default, this automatically initiates permission checks and begins listening
* unless `startOnInit: false` was passed in {@link setConfig}.
* Update frequency is throttled to `RotationListenerConfig.pollingRate` (default: 60 FPS).
*
* @param callback - Function invoked on each orientation tick with a {@link RotationUpdate}.
*
* @example
* ```typescript
* import { deviceRotation } from "web-orientation";
*
* deviceRotation.onUpdate((update) => {
* const { x, y, z, w } = update.quaternion;
* console.log(`Quaternion: x=${x}, y=${y}, z=${z}, w=${w}`);
* });
* ```
*/
export const onUpdate = (callback: (update: RotationUpdate) => void): void => {
cb = callback;
if (cfg.startOnInit) {
getPermission().then(() => {
beginListening();
});
}
};
const onOrientationEvent = (event: {
beta: number | null;
gamma: number | null;
alpha: number | null;
}) => {
const now = Date.now();
// Throttle updates per second
const fps = cfg.pollingRate;
if (now - lastUpdate < 1000 / fps) {
return;
}
lastUpdate = now;
// Rotation values. beta = X, gamma = Y, alpha = Z
const beta = event.beta;
const gamma = event.gamma;
const alpha = event.alpha;
// If sensors are still initializing
if (!beta || !gamma || !alpha) {
return;
}
const currentRotation = quaternionFromOrientationEvent(beta, gamma, alpha);
emitRotation(currentRotation);
};
export const emitRotation = (rotation: Quaternion) => {
if (!rotationOffset) {
// Calculate the rotation offset we need to apply in order
// to get the model rotated to the 0 position
rotationOffset = getOffsetQuaternion(rotation);
}
const finalRotation = applyQuaternionOffset(rotation, rotationOffset);
const update: RotationUpdate = {
quaternion: finalRotation,
};
cb(update);
};
const beginListening = async () => {
tryInjectMockContainer();
if ((await permissionFunction?.()) === "denied") {
console.warn(
"Orientation permission was not given before orientation listening started",
);
}
if (!(window as any).Gyroscope) {
console.warn(
"Browser does not support Generic Sensor API, falling back to using legacy orientation sensor API.",
);
//TODO: Add sensor api fallback
}
window.addEventListener("deviceorientation", onOrientationEvent);
cfg.onStart();
};
const getPermission = async () => {
const permissionFunction = cfg.customPermissionFunction ?? requestPermission;
let tries = 0;
if (hasPermission === true) {
cfg.onPermissionGranted();
return;
}
while (hasPermission === false && tries < 5) {
hasPermission = await permissionFunction();
if (!hasPermission) {
console.error(
"Device rejected device orientation request, trying again...",
);
cfg.onPermissionDenied();
tries += 1;
} else {
cfg.onPermissionGranted();
tries = 0;
}
}
};
/**
* Manually starts listening for orientation updates.
*
* Requests sensor permissions if not yet granted, and attaches device orientation listeners.
* Note: Browsers require a secure context (`HTTPS`) for device sensor APIs.
*
* @example
* ```typescript
* import { deviceRotation } from "web-orientation";
*
* // When startOnInit is set to false:
* document.getElementById("start-btn").addEventListener("click", () => {
* deviceRotation.start();
* });
* ```
*/
export const start = () => {
if (!window.isSecureContext) {
console.warn(
"Page is not being served in a secure context (HTTPS)." +
"Orientation will not work on some devices/browsers",
);
}
getPermission().then(() => {
beginListening();
});
};
/**
* Stops listening for device orientation updates and detaches event listeners.
*
* Use this when navigating away from a view or unmounting components to save battery and resources.
*
* @example
* ```typescript
* import { deviceRotation } from "web-orientation";
*
* onDestroy(() => {
* deviceRotation.stop();
* });
* ```
*/
export const stop = () => {
window.removeEventListener("deviceorientation", onOrientationEvent);
lastUpdate = 0;
};
/**
* Resets and calibrates the reference frame, setting the current physical orientation as origin `(0, 0, 0, 1)`.
*
* Emits an immediate identity quaternion update through the registered {@link onUpdate} callback.
* Subsequent updates are rotated relative to this new zero reference.
*
* @example
* ```typescript
* import { deviceRotation } from "web-orientation";
*
* document.getElementById("recalibrate-btn").addEventListener("click", () => {
* deviceRotation.resetOrientation();
* });
* ```
*/
export const resetOrientation = () => {
rotationOffset = null;
// Zero Quaternion
cb({ quaternion: new Quaternion(1, 0, 0, 0) });
};