Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

pointer-tracker.ts 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. /*
  2. * encantar.js
  3. * GPU-accelerated Augmented Reality for the web
  4. * Copyright (C) 2022-2025 Alexandre Martins <alemartf(at)gmail.com>
  5. *
  6. * This program is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Lesser General Public License as published
  8. * by the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  18. *
  19. * pointer-tracker.ts
  20. * Tracker of pointer-based input
  21. */
  22. import Speedy from 'speedy-vision';
  23. import { SpeedyPromise } from 'speedy-vision/types/core/speedy-promise';
  24. import { TrackerResult, TrackerOutput, Tracker, TrackerType } from '../tracker';
  25. import { TrackablePointer, TrackablePointerPhase } from './trackable-pointer';
  26. import { PointerSource } from '../../sources/pointer-source';
  27. import { Vector2 } from '../../geometry/vector2';
  28. import { Utils, Nullable } from '../../utils/utils';
  29. import { IllegalOperationError, IllegalArgumentError } from '../../utils/errors';
  30. import { Session } from '../../core/session';
  31. import { Viewport } from '../../core/viewport';
  32. /**
  33. * A result of a PointerTracker. It's meant to be consumed by the user/application
  34. */
  35. export class PointerTrackerResult extends TrackerResult
  36. {
  37. /** the tracker that generated this result */
  38. readonly tracker: PointerTracker;
  39. /** the trackables */
  40. readonly trackables: TrackablePointer[];
  41. /**
  42. * Constructor
  43. * @param tracker
  44. * @param trackables
  45. */
  46. constructor(tracker: PointerTracker, trackables: TrackablePointer[])
  47. {
  48. super();
  49. this.tracker = tracker;
  50. this.trackables = trackables;
  51. }
  52. }
  53. /**
  54. * The output of a PointerTracker in a particular Frame of a Session
  55. */
  56. export interface PointerTrackerOutput extends TrackerOutput
  57. {
  58. /** tracker result to be consumed by the user */
  59. readonly exports: PointerTrackerResult;
  60. }
  61. /**
  62. * The space in which pointers are located.
  63. *
  64. * - In "normalized" space, pointers are located in [-1,1]x[-1,1]. The origin
  65. * of the space is at the center of the viewport. The x-axis points to the
  66. * right and the y-axis points up. This is the default space.
  67. *
  68. * - Point (0,0) is at the center of the viewport
  69. * - The top-right corner of the viewport is at (+1,+1)
  70. * - The bottom-left corner of the viewport is at (-1,-1)
  71. *
  72. * - The "adjusted" space is similar to the normalized space, except that it is
  73. * scaled so that it matches the aspect ratio of the viewport.
  74. *
  75. * Pointers in adjusted space are contained in normalized space, but unless
  76. * the viewport is a square, one of their coordinates, x or y, will no longer
  77. * range from -1 to +1. It will range from -s to +s, where s = min(a, 1/a).
  78. * In this expression, a is the aspect ratio of the viewport and s is less
  79. * than or equal to 1.
  80. *
  81. * Selecting the adjusted space is useful for making sure that pointer speeds
  82. * are equivalent in both axes and for preserving movement curves. Speeds are
  83. * not equivalent and movement curves are not preserved by default because
  84. * the normalized space is a square, whereas the viewport is a rectangle.
  85. *
  86. * In summary, prefer the adjusted space when working with velocities and
  87. * movement curves.
  88. */
  89. export type PointerSpace = 'normalized' | 'adjusted';
  90. /**
  91. * Options for instantiating a PointerTracker
  92. */
  93. export interface PointerTrackerOptions
  94. {
  95. /** the space in which pointers will be located */
  96. space?: PointerSpace;
  97. }
  98. /** Convert event type to trackable pointer phase */
  99. const EVENTTYPE2PHASE: Record<string, TrackablePointerPhase> = {
  100. 'pointerdown': 'began',
  101. 'pointerup': 'ended',
  102. 'pointermove': 'moved',
  103. 'pointercancel': 'canceled',
  104. 'pointerleave': 'ended',
  105. 'pointerenter': 'began',
  106. };
  107. /** Default options for instantiating a PointerTracker */
  108. const DEFAULT_OPTIONS: Readonly<Required<PointerTrackerOptions>> = {
  109. space: 'normalized'
  110. };
  111. /**
  112. * A tracker of pointer-based input such as mouse, touch or pen
  113. */
  114. export class PointerTracker implements Tracker
  115. {
  116. /** the source of data */
  117. private _source: Nullable<PointerSource>;
  118. /** the viewport */
  119. private _viewport: Nullable<Viewport>;
  120. /** pointer space */
  121. private _space: PointerSpace;
  122. /** active pointers */
  123. private _activePointers: Map<number, TrackablePointer>;
  124. /** new pointers */
  125. private _newPointers: Map<number, TrackablePointer>;
  126. /** helper map for normalizing IDs */
  127. private _idMap: Map<number, number>;
  128. /** previous output */
  129. private _previousOutput: PointerTrackerOutput;
  130. /** time of the previous update */
  131. private _previousUpdateTime: DOMHighResTimeStamp;
  132. /** helper flag */
  133. private _wantToReset: boolean;
  134. /** auto-increment ID */
  135. private _nextId: number;
  136. /**
  137. * Constructor
  138. * @param options
  139. */
  140. constructor(options: PointerTrackerOptions)
  141. {
  142. const settings = this._buildSettings(options);
  143. this._source = null;
  144. this._viewport = null;
  145. this._space = settings.space;
  146. this._activePointers = new Map();
  147. this._newPointers = new Map();
  148. this._idMap = new Map();
  149. this._nextId = 1;
  150. this._previousOutput = this._generateOutput();
  151. this._previousUpdateTime = Number.POSITIVE_INFINITY;
  152. this._wantToReset = false;
  153. this._resetInTheNextUpdate = this._resetInTheNextUpdate.bind(this);
  154. }
  155. /**
  156. * Build a full and validated options object
  157. * @param options
  158. * @returns validated options with defaults
  159. */
  160. private _buildSettings(options: PointerTrackerOptions): Required<PointerTrackerOptions>
  161. {
  162. const settings: Required<PointerTrackerOptions> = Object.assign({}, DEFAULT_OPTIONS, options);
  163. if(settings.space != 'normalized' && settings.space != 'adjusted')
  164. throw new IllegalArgumentError(`Invalid pointer space: "${settings.space}"`);
  165. return settings;
  166. }
  167. /**
  168. * The type of the tracker
  169. * @deprecated use is() instead
  170. */
  171. get type(): keyof TrackerType
  172. {
  173. return 'pointer-tracker';
  174. }
  175. /**
  176. * Check if this tracker is of a certain type
  177. */
  178. is<T extends keyof TrackerType>(type: T): this is TrackerType[T]
  179. {
  180. return type === this.type;
  181. }
  182. /**
  183. * Initialize the tracker
  184. * @param session
  185. * @returns a promise that is resolved as soon as the tracker is initialized
  186. * @internal
  187. */
  188. _init(session: Session): SpeedyPromise<void>
  189. {
  190. Utils.log('Initializing PointerTracker...');
  191. // set the viewport
  192. this._viewport = session.viewport;
  193. // find the pointer source
  194. for(const source of session.sources) {
  195. if(source._is('pointer-source')) {
  196. this._source = source;
  197. break;
  198. }
  199. }
  200. if(this._source === null)
  201. return Speedy.Promise.reject(new IllegalOperationError('A PointerTracker expects a PointerSource'));
  202. // link the pointer source to the viewport
  203. this._source._setViewport(this._viewport);
  204. // reset trackables
  205. document.addEventListener('visibilitychange', this._resetInTheNextUpdate);
  206. // done!
  207. return Speedy.Promise.resolve();
  208. }
  209. /**
  210. * Release the tracker
  211. * @returns a promise that is resolved as soon as the tracker is released
  212. * @internal
  213. */
  214. _release(): SpeedyPromise<void>
  215. {
  216. this._source = null;
  217. this._viewport = null;
  218. this._activePointers.clear();
  219. this._newPointers.clear();
  220. this._idMap.clear();
  221. document.removeEventListener('visibilitychange', this._resetInTheNextUpdate);
  222. return Speedy.Promise.resolve();
  223. }
  224. /**
  225. * Update the tracker (update cycle)
  226. * @returns a promise that is resolved as soon as the tracker is updated
  227. * @internal
  228. */
  229. _update(): SpeedyPromise<void>
  230. {
  231. const canvas = this._viewport!.canvas;
  232. const rect = canvas.getBoundingClientRect(); // may be different in different frames!
  233. // find the time between this and the previous update of this tracker
  234. const deltaTime = this._updateTime();
  235. const inverseDeltaTime = (deltaTime > 1e-5) ? 1 / deltaTime : 60; // 1/dt = 1 / (1/60) with 60 fps
  236. // remove inactive trackables from the previous frame (update cycle)
  237. const inactiveTrackables = this._findInactiveTrackables();
  238. for(let i = inactiveTrackables.length - 1; i >= 0; i--)
  239. this._activePointers.delete(inactiveTrackables[i].id);
  240. // make all active trackables stationary
  241. this._updateAllTrackables({
  242. phase: 'stationary',
  243. velocity: Vector2.ZERO,
  244. deltaPosition: Vector2.ZERO
  245. });
  246. // want to reset?
  247. if(this._wantToReset) {
  248. this._reset();
  249. this._wantToReset = false;
  250. }
  251. // consume events
  252. let event: Nullable<PointerEvent>;
  253. while((event = this._source!._consume()) !== null) {
  254. // sanity check
  255. if(event.target !== canvas)
  256. return Speedy.Promise.reject(new IllegalOperationError('Invalid PointerEvent target ' + event.target));
  257. else if(!EVENTTYPE2PHASE.hasOwnProperty(event.type))
  258. return Speedy.Promise.reject(new IllegalOperationError('Invalid PointerEvent type ' + event.type));
  259. // determine the ID
  260. const id = this._normalizeId(event.pointerId, event.pointerType);
  261. // determine the previous states, if any, of the trackable
  262. const previous = this._activePointers.get(id); // state in the previous frame
  263. const current = this._newPointers.get(id); // previous state in the current frame
  264. // determine the phase
  265. const phase = EVENTTYPE2PHASE[event.type];
  266. // new trackables always begin with a pointerdown event,
  267. // or with a pointerenter event having buttons pressed
  268. // (example: a mousemove without a previous mousedown isn't tracked)
  269. if(!(event.type == 'pointerdown' || (event.type == 'pointerenter' && event.buttons > 0))) {
  270. if(!previous && !current)
  271. continue; // discard event
  272. }
  273. else if(previous) {
  274. // discard a 'began' after another 'began'
  275. continue;
  276. }
  277. else if(event.button != 0 && event.pointerType == 'mouse') {
  278. // require left mouse click
  279. continue;
  280. }
  281. // discard event if 'began' and 'ended' happened in the same frame
  282. // (difficult to reproduce, but it can be done ;)
  283. if(!previous) {
  284. if(phase == 'ended' || phase == 'canceled') {
  285. this._newPointers.delete(id);
  286. continue;
  287. }
  288. }
  289. // what if we receive 'began' after 'ended' in the same frame?
  290. else if(phase == 'began' && current) {
  291. if(current.phase == 'ended' || current.phase == 'canceled') {
  292. this._newPointers.delete(id);
  293. continue;
  294. }
  295. }
  296. // discard previously canceled pointers (e.g., with a visibilitychange event)
  297. if(previous?.phase == 'canceled')
  298. continue;
  299. // more special rules
  300. switch(event.type) {
  301. case 'pointermove':
  302. if(event.buttons == 0 || current?.phase == 'began')
  303. continue;
  304. break;
  305. case 'pointerenter':
  306. if(event.buttons == 0 || previous?.phase == 'began' || current?.phase == 'began')
  307. continue;
  308. break;
  309. case 'pointercancel': // purge everything
  310. this._reset();
  311. this._newPointers.clear();
  312. continue;
  313. }
  314. // determine the current position in normalized space
  315. const absX = event.pageX - (rect.left + window.scrollX);
  316. const absY = event.pageY - (rect.top + window.scrollY);
  317. const relX = 2 * absX / rect.width - 1; // convert to [-1,1]
  318. const relY = -(2 * absY / rect.height - 1); // flip Y axis
  319. const position = new Vector2(relX, relY);
  320. // scale the normalized space so that it matches the aspect ratio of the viewport
  321. if(this._space == 'adjusted') {
  322. const a = this._viewport!.aspectRatio;
  323. if(a >= 1) {
  324. // landscape
  325. position._set(relX, relY / a);
  326. }
  327. else {
  328. // portrait
  329. position._set(relX * a, relY);
  330. }
  331. }
  332. // determine the position delta
  333. const deltaPosition = !previous ? Vector2.ZERO :
  334. position._clone()._subtract(previous.position);
  335. // determine the initial position
  336. const initialPosition = previous ? previous.initialPosition :
  337. Object.freeze(position._clone()) as Vector2;
  338. // determine the velocity
  339. const velocity = deltaPosition._clone()._scale(inverseDeltaTime);
  340. // determine the elapsed time since the tracking began
  341. const duration = previous ? previous.duration + deltaTime : 0;
  342. // determine how much this pointer has moved since its tracking began
  343. const movementLength = previous ? previous.movementLength + deltaPosition.length() : 0;
  344. // determine the duration of the movement
  345. const movementDuration = !previous ? 0 : previous.movementDuration + (movementLength > previous.movementLength ? deltaTime : 0);
  346. // determine whether or not this is the primary pointer for this type
  347. const isPrimary = event.isPrimary;
  348. // determine the type of the originating device
  349. const kind = event.pointerType;
  350. // we create new trackable instances on each frame;
  351. // these will be exported and consumed by the user
  352. this._newPointers.set(id, {
  353. id, phase,
  354. position, deltaPosition, initialPosition,
  355. velocity, duration,
  356. movementDuration, movementLength,
  357. isPrimary, kind,
  358. tracker: this
  359. });
  360. }
  361. // update trackables
  362. this._newPointers.forEach((trackable, id) => this._activePointers.set(id, trackable));
  363. this._newPointers.clear();
  364. this._advanceAllStationaryTrackables(deltaTime);
  365. // discard unused IDs
  366. if(this._activePointers.size == 0 && this._idMap.size > 0)
  367. this._idMap.clear();
  368. // generate output
  369. this._previousOutput = this._generateOutput();
  370. // test
  371. //console.log(JSON.stringify(this._prevOutput.exports.trackables, null, 4));
  372. // done!
  373. return Speedy.Promise.resolve();
  374. }
  375. /**
  376. * Output of the previous frame
  377. * @internal
  378. */
  379. get _output(): PointerTrackerOutput
  380. {
  381. return this._previousOutput;
  382. }
  383. /**
  384. * Stats info
  385. * @internal
  386. */
  387. get _stats(): string
  388. {
  389. const n = this._activePointers.size;
  390. const s = n != 1 ? 's' : '';
  391. return n + ' pointer' + s;
  392. }
  393. /**
  394. * The space in which pointers are located.
  395. * You may set it when instantiating the tracker.
  396. */
  397. get space(): PointerSpace
  398. {
  399. return this._space;
  400. }
  401. /**
  402. * Generate tracker output
  403. * @returns a new PointerTrackerOutput object
  404. */
  405. private _generateOutput(): PointerTrackerOutput
  406. {
  407. const trackables: TrackablePointer[] = [];
  408. this._activePointers.forEach(trackable => trackables.push(trackable));
  409. const result = new PointerTrackerResult(this, this._sortTrackables(trackables));
  410. return { exports: result };
  411. }
  412. /**
  413. * Update all active pointers
  414. * @param fields
  415. */
  416. private _updateAllTrackables(fields: Partial<TrackablePointer>): void
  417. {
  418. this._activePointers.forEach((trackable, id) => {
  419. this._activePointers.set(id, Object.assign({}, trackable, fields));
  420. });
  421. }
  422. /**
  423. * Advance the elapsed time of all stationary pointers
  424. * @param deltaTime
  425. */
  426. private _advanceAllStationaryTrackables(deltaTime: number): void
  427. {
  428. this._activePointers.forEach((trackable, id) => {
  429. if(trackable.phase == 'stationary') {
  430. (trackable as any).duration += deltaTime;
  431. /*
  432. this._activePointers.set(id, Object.assign({}, trackable, {
  433. duration: trackable.duration + deltaTime
  434. }));
  435. */
  436. }
  437. });
  438. }
  439. /**
  440. * Normalize pointer IDs across browsers
  441. * @param pointerId browser-provided pointer ID
  442. * @param pointerType pointer type
  443. * @returns a normalized pointer ID
  444. */
  445. private _normalizeId(pointerId: number, pointerType: string): number
  446. {
  447. // XXX different hardware devices acting simultaneously may produce
  448. // events with the same pointerId - handling this seems overkill?
  449. if(pointerType == 'mouse')
  450. return 0;
  451. if(!this._idMap.has(pointerId))
  452. this._idMap.set(pointerId, this._nextId++);
  453. return this._idMap.get(pointerId)!;
  454. }
  455. /**
  456. * Cancel all active pointers and consume all events
  457. * @param deltaTime
  458. */
  459. private _reset(): void
  460. {
  461. // cancel all active pointers
  462. this._updateAllTrackables({
  463. phase: 'canceled',
  464. velocity: Vector2.ZERO,
  465. deltaPosition: Vector2.ZERO
  466. });
  467. // consume all events
  468. while(this._source!._consume() !== null);
  469. }
  470. /**
  471. * Reset in the next update of the tracker
  472. */
  473. private _resetInTheNextUpdate(): void
  474. {
  475. this._wantToReset = true;
  476. }
  477. /**
  478. * As a convenience, let's make sure that a primary pointer, if any exists,
  479. * is at the beginning of the trackables array
  480. * @param trackables
  481. * @returns sorted trackables
  482. */
  483. private _sortTrackables(trackables: TrackablePointer[]): TrackablePointer[]
  484. {
  485. /*
  486. Note: the browser may not report a new unique pointer (phase: "began")
  487. as primary. This logic makes trackables[0] primary, or sort of primary.
  488. Behavior on Chrome 130 on Android: when moving multiple touch points,
  489. remove focus from the browser. Touch points will be canceled as
  490. expected. When touching the screen again with a single finger, the
  491. (only one) registered pointer will not be primary. That's undesirable.
  492. Touching the screen again with multiple fingers (none will be primary),
  493. and then releasing them, will restore the desired behavior.
  494. */
  495. // nothing to do
  496. if(trackables.length <= 1 || trackables[0].isPrimary)
  497. return trackables;
  498. // find a primary pointer and swap
  499. for(let j = 1; j < trackables.length; j++) {
  500. if(trackables[j].isPrimary) {
  501. const primary = trackables[j];
  502. trackables[j] = trackables[0];
  503. trackables[0] = primary;
  504. break;
  505. }
  506. }
  507. // done!
  508. return trackables;
  509. }
  510. /**
  511. * Find trackables to remove
  512. * @returns a list of trackables to remove
  513. */
  514. private _findInactiveTrackables(): TrackablePointer[]
  515. {
  516. const trackables: TrackablePointer[] = [];
  517. this._activePointers.forEach(trackable => {
  518. if(trackable.phase == 'ended' || trackable.phase == 'canceled')
  519. trackables.push(trackable);
  520. });
  521. return trackables;
  522. }
  523. /**
  524. * Update the time
  525. * @returns delta time in seconds
  526. */
  527. private _updateTime(): DOMHighResTimeStamp
  528. {
  529. const now = performance.now() * 0.001;
  530. if(this._previousUpdateTime > now)
  531. this._previousUpdateTime = now;
  532. const prev = this._previousUpdateTime;
  533. this._previousUpdateTime = now;
  534. return now - prev;
  535. }
  536. }