trackball
Version:
The RAW library to add a virtual Trackball to your DOM
347 lines (289 loc) • 11.6 kB
JavaScript
/**
* @license Trackball.js v1.3.1 8/16/2025
* https://raw.org/article/trackball-rotation-using-quaternions/
*
* Copyright (c) 2025, Robert Eisele (https://raw.org/)
* Licensed under the MIT license.
**/
import Quaternion from 'quaternion';
// Constructor
function Trackball(opts) {
if (!opts) opts = {};
if (opts['nodeType']) opts = { 'scene': opts };
if (typeof opts['scene'] === 'string') opts['scene'] = document.querySelector(opts['scene']);
const scene = opts['scene'];
if (!scene) throw new Error('[Trackball] opts.scene is required');
this['opts'] = {
'onDraw': typeof opts['onDraw'] === 'function' ? opts['onDraw'] : function () { },
'onStart': typeof opts['onStart'] === 'function' ? opts['onStart'] : function () { },
'onEnd': typeof opts['onEnd'] === 'function' ? opts['onEnd'] : function () { },
'enabled': opts['enabled'] !== false,
'limitAxis': opts['limitAxis'] ?? null,
'invertX': !!opts['invertX'],
'invertY': !!opts['invertY'],
'speed': opts['speed'] ?? 1,
'ballsize': opts['ballsize'] ?? 0.75,
'border': opts['border'] ?? 0.5,
'inertia': (typeof opts['inertia'] === 'object' || opts['inertia'] === true) ? (opts['inertia'] || {}) : false,
'usePointerCapture': opts['usePointerCapture'] !== false,
'passiveMove': opts['passiveMove'] !== false,
'activeArea': opts['activeArea'] ?? null,
'areaPolicy': opts['areaPolicy'] || 'inside-only'
};
this['scene'] = scene;
this['q'] = this['p'] = opts['q'] || Quaternion['ONE']; // current & base
// Bind handlers to instance
this['_onPointerDown'] = onPointerDown.bind(this);
this['_onPointerMove'] = onPointerMove.bind(this);
this['_onPointerUp'] = onPointerUp.bind(this);
// Events
this['scene'].addEventListener('pointerdown', this['_onPointerDown'], { 'passive': true });
document.addEventListener('pointermove', this['_onPointerMove'], { 'passive': !!this['opts']['passiveMove'] });
document.addEventListener('pointerup', this['_onPointerUp'], { 'passive': true });
document.addEventListener('pointercancel', this['_onPointerUp'], { 'passive': true });
// Initial draw
draw.call(this);
}
Trackball['prototype'] = {
'scene': null,
'opts': null,
'drag': null, // { startVec: [x,y,z], startPos: [x,y], rect: DOMRect }
'rafPending': false,
'lastPointer': null, // { x, y }
'inertiaID': 0,
'velocityAxis': null, // [x,y,z] unit axis
'velocity': 0, // rad/frame
'q': null,
'p': null,
'angleChange': 0,
'angle': 0,
'spinAxis': null,
'spinBase': null,
't0': 0,
't1': 0,
'lastDQ': null,
'_onPointerDown': null,
'_onPointerMove': null,
'_onPointerUp': null,
'rotate': function (by) { // Programmatic rotation: q := q * by (only when idle)
if (!this['drag'] && !this['inertiaID']) {
this['q'] = this['p'] = this['p']['mul'](by);
draw.call(this);
}
},
'setQuaternion': function (q) { // Replace orientation immediately
stopInertia.call(this);
this['drag'] = null;
this['q'] = this['p'] = q;
draw.call(this);
},
'setActiveArea': function (area) { // Update the active interaction area
this['opts']['activeArea'] = area;
},
'setEnabled': function (v) { // Enable/disable interaction
this['opts']['enabled'] = !!v;
},
'dispose': function () { // Cleanup
stopInertia.call(this);
this['scene'].removeEventListener('pointerdown', this['_onPointerDown']);
document.removeEventListener('pointermove', this['_onPointerMove']);
document.removeEventListener('pointerup', this['_onPointerUp']);
document.removeEventListener('pointercancel', this['_onPointerUp']);
}
};
// === Private (module-local) helpers ===
function onPointerDown(ev) {
if (!this['opts']['enabled']) return;
const rect = getActiveRect.call(this);
const inside = inRect(ev['clientX'], ev['clientY'], rect);
if (!inside && this['opts']['areaPolicy'] !== 'always') return;
stopInertia.call(this);
this['velocity'] = 0;
this['velocityAxis'] = null;
this['t0'] = this['t1'] = performance.now();
this['lastDQ'] = null;
if (this['opts']['usePointerCapture'] && ev['target'] && 'setPointerCapture' in ev['target']) {
try { ev['target']['setPointerCapture'](ev['pointerId']); } catch (e) { }
}
const startVec = project.call(this, ev['clientX'], ev['clientY'], rect);
this['drag'] = {
'startVec': startVec,
'prevVec': startVec,
'startPos': [ev['clientX'], ev['clientY']],
'rect': rect
};
this['p'] = this['q'];
this['lastPointer'] = { 'x': ev['clientX'], 'y': ev['clientY'] };
this['opts']['onStart'] && this['opts']['onStart'](this['q']);
draw.call(this);
}
function onPointerMove(ev) {
if (!this['drag']) return;
this['lastPointer'] = { 'x': ev['clientX'], 'y': ev['clientY'] };
if (!this['rafPending']) {
this['rafPending'] = true;
requestAnimationFrame(tick.bind(this));
}
}
function onPointerUp(ev) {
if (!this['drag']) return;
// Capture release time for inertia dt
this['t1'] = performance.now();
const rect = this['drag']['rect'];
const inside = inRect(ev['clientX'], ev['clientY'], rect);
if (this['opts']['areaPolicy'] !== 'inside-only' || inside) {
updateFromPointer.call(this, ev['clientX'], ev['clientY']);
}
// Commit the drag result so subsequent programmatic rotate() composes correctly
this['p'] = this['q'];
this['drag'] = null;
if (this['opts']['inertia']) {
const cfg = this['opts']['inertia'] || {};
startInertia.call(this, cfg['damping'] ?? 0.93, cfg['minVelocity'] ?? 1e-3, cfg['maxStep'] ?? 0.2, cfg['timeDiv'] ?? 50);
return; // onEnd will be fired when inertia stops
}
this['opts']['onEnd'] && this['opts']['onEnd'](this['q']);
draw.call(this);
}
function tick() {
this['rafPending'] = false;
if (!this['drag'] || !this['lastPointer']) return;
const x = this['lastPointer']['x'];
const y = this['lastPointer']['y'];
const rect = this['drag']['rect'];
const inside = inRect(x, y, rect);
if (this['opts']['areaPolicy'] === 'inside-only' && !inside) return;
const dynamicRect = maybeRefreshRect.call(this);
if (dynamicRect) this['drag']['rect'] = dynamicRect;
updateFromPointer.call(this, x, y);
draw.call(this);
}
function updateFromPointer(clientX, clientY) {
const rect = this['drag']['rect'];
// Apply optional inversion relative to drag start
const invX = this['opts']['invertX'] ? (2 * this['drag']['startPos'][0] - clientX) : clientX;
const invY = this['opts']['invertY'] ? (2 * this['drag']['startPos'][1] - clientY) : clientY;
const cur = project.call(this, invX, invY, rect);
// Absolute delta from drag-start controls orientation (keeps prior feel)
const dqAbs = Quaternion['fromVectors'](this['drag']['startVec'], cur);
this['q'] = dqAbs['mul'](this['p']);
this['lastDQ'] = dqAbs;
this['drag']['prevVec'] = cur;
}
function startInertia(damping, minVelocity, maxStep, timeDiv) {
if (this['inertiaID']) cancelAnimationFrame(this['inertiaID']);
// Use the last drag delta (from startVec -> last cur) like v1 did
const dq = this['lastDQ'];
if (!dq) return;
// Angle and axis from dq
const w = Math.max(-1, Math.min(1, dq['w']));
const dw = 2 * Math.acos(w);
const s = Math.sqrt(Math.max(0, 1 - w * w));
const axis = s > 1e-12 ? [dq['x'] / s, dq['y'] / s, dq['z'] / s] : [0, 0, 1];
// Time scaling like v1: dt = max((Δt)/timeDiv, 1)
const dt = Math.max((this['t1'] - this['t0']) / (timeDiv || 50), 1);
// Initial per-frame step (bounded)
this['angleChange'] = Math.min(maxStep || 0.2, Math.abs(dw / dt));
if (!(this['angleChange'] > 0)) return;
this['angle'] = 0;
this['spinAxis'] = axis;
this['spinBase'] = this['p']; // base orientation at release
const self = this;
function slide() {
self['angle'] += self['angleChange'];
self['angleChange'] *= (damping || 0.93);
self['q'] = Quaternion['fromAxisAngle'](self['spinAxis'], self['angle'])['mul'](self['spinBase']);
draw.call(self);
if (self['angleChange'] < minVelocity) {
stopInertia.call(self);
self['p'] = self['q'];
self['opts']['onEnd'] && self['opts']['onEnd'](self['q']);
} else {
self['inertiaID'] = requestAnimationFrame(slide);
}
}
this['inertiaID'] = requestAnimationFrame(slide);
}
function stopInertia() {
if (this['inertiaID']) cancelAnimationFrame(this['inertiaID']);
this['inertiaID'] = 0;
this['velocity'] = 0;
this['velocityAxis'] = null;
}
function draw() {
this['opts']['onDraw'] && this['opts']['onDraw'](this['q']);
}
function getActiveRect() {
const area = this['opts']['activeArea'];
if (!area) return this['scene']['getBoundingClientRect']();
if (typeof area === 'function') return toDOMRect(area());
if (typeof area === 'string') {
const el = document['querySelector'](area);
return el && el instanceof HTMLElement ? el['getBoundingClientRect']() : this['scene']['getBoundingClientRect']();
}
if (area instanceof HTMLElement) return area['getBoundingClientRect']();
return toDOMRect(area);
}
function maybeRefreshRect() {
const area = this['opts']['activeArea'];
if (typeof area === 'function' || area instanceof HTMLElement || typeof area === 'string') return getActiveRect.call(this);
return null;
}
function toDOMRect(r) {
if (!r) return document.body.getBoundingClientRect();
if (typeof DOMRect !== 'undefined' && r instanceof DOMRect) return r;
const left = r['left'] ?? r['x'] ?? 0;
const top = r['top'] ?? r['y'] ?? 0;
const width = r['width'] ?? 0;
const height = r['height'] ?? 0;
const right = left + width;
const bottom = top + height;
return { 'left': left, 'top': top, 'right': right, 'bottom': bottom, 'width': width, 'height': height, 'x': left, 'y': top, 'toJSON': function () { return this; } };
}
function inRect(x, y, r) {
return x >= r['left'] && x <= r['right'] && y >= r['top'] && y <= r['bottom'];
}
// Maps client coords to unit hemisphere with rounded rim, controlled by 'border' and 'ballsize'.
function project(clientX, clientY, rect) {
const maxDim = Math.max(rect['width'], rect['height']) - 1;
// Map to [-1, 1] in local rect space
let x = (2 * (clientX - rect['left']) - rect['width'] - 1) / maxDim;
let y = (2 * (clientY - rect['top']) - rect['height'] - 1) / maxDim;
// Axis limiting & ball size
if (this['opts']['limitAxis'] === 'x') x = 0; else x /= this['opts']['ballsize'];
if (this['opts']['limitAxis'] === 'y') y = 0; else y /= this['opts']['ballsize'];
const border = this['opts']['border'];
const ra = 1 + border; // outer radius
const a = border * (1 + border / 2);
const ri = 2 / (ra + 1 / ra);
const dist2 = (x * x + y * y) * (ra * ra);
const dist = Math.sqrt(dist2);
if (dist < ri) return [x, y, Math.sqrt(1 - dist2)];
if (dist < ra) {
const dr = ra - dist;
return [x, y, a - Math.sqrt((a + dr) * (a - dr))];
}
return [x, y, 0];
}
// === Example usage ===
/*
const tb = new Trackball({
'scene': '.scene',
'q': Quaternion.random(),
'inertia': { 'damping': 0.92, 'minVelocity': 1e-3 },
'limitAxis': null,
'areaPolicy': 'start-inside',
'border': 0.5,
'ballsize': 0.75,
'onDraw': function(q) {
// apply q (Quaternion) to your view matrix / object here
// e.g., shader uniform from q.toMatrix4() if your lib supports it
document.querySelector('.cube').style.transform = q.toCSSTransform();
}
});
const inc = Quaternion.fromAxisAngle([Math.random(), Math.random(), Math.random()], 1/100);
(function loop(){
tb['rotate'](inc);
requestAnimationFrame(loop);
})();
*/