@expofp/renderer 3.40.2 → 3.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controls/Controls.d.ts +6 -2
- package/dist/controls/Controls.js +1 -1
- package/dist/controls/gestures.d.ts +3 -2
- package/dist/controls/gestures.js +13 -4
- package/dist/controls/handlers/DoubleTapZoom.d.ts +23 -0
- package/dist/controls/handlers/DoubleTapZoom.js +82 -0
- package/dist/controls/handlers/Inertia.d.ts +31 -16
- package/dist/controls/handlers/Inertia.js +82 -75
- package/dist/controls/handlers/Pan.d.ts +1 -1
- package/dist/controls/handlers/Pan.js +183 -3
- package/dist/controls/handlers/Pitch.js +11 -2
- package/dist/controls/handlers/Roll.js +17 -3
- package/dist/controls/handlers/Zoom.d.ts +21 -8
- package/dist/controls/handlers/Zoom.js +17 -9
- package/dist/controls/index.d.ts +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/types/events.d.ts +14 -5
- package/package.json +3 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type PanConfig } from './handlers/Pan';
|
|
3
3
|
import { type RollConfig } from './handlers/Roll';
|
|
4
|
+
import { type ZoomConfig } from './handlers/Zoom';
|
|
4
5
|
/**
|
|
5
6
|
* What the user may do with the plan.
|
|
6
7
|
*
|
|
@@ -18,8 +19,11 @@ import { type RollConfig } from './handlers/Roll';
|
|
|
18
19
|
export interface ControlsConfig {
|
|
19
20
|
/** Left-drag and one-finger drag to pan the plan. Defaults on. */
|
|
20
21
|
pan?: boolean | PanConfig;
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Wheel and two-finger pinch to zoom, toward the cursor. Defaults on; a double-click or
|
|
24
|
+
* double-tap zooms too only when its options ask for it.
|
|
25
|
+
*/
|
|
26
|
+
zoom?: boolean | ZoomConfig;
|
|
23
27
|
/** Right-drag and two-finger twist to turn the plan. Defaults to a development build. */
|
|
24
28
|
roll?: boolean | RollConfig;
|
|
25
29
|
/** Right-drag and two-finger vertical drag to tilt the plan. Defaults to a development build. */
|
|
@@ -34,7 +34,7 @@ export function Controls(props) {
|
|
|
34
34
|
// which is where the controls already orbit.
|
|
35
35
|
void controlsRef.current.rotateTo(0, 0);
|
|
36
36
|
}, []);
|
|
37
|
-
return (_jsxs(_Fragment, { children: [_jsx(CameraControls, { ref: controlsRef, makeDefault: true, events: true, dollyToCursor: true, controlSmoothTime: { rotate: 0, truck: 0 } }), pan !== false && _jsx(Pan, { ...optionsOf(pan) }), zoom !== false && _jsx(Zoom, {}), roll !== false && _jsx(Roll, { ...optionsOf(roll) }), pitch !== false && _jsx(Pitch, {})] }));
|
|
37
|
+
return (_jsxs(_Fragment, { children: [_jsx(CameraControls, { ref: controlsRef, makeDefault: true, events: true, dollyToCursor: true, controlSmoothTime: { rotate: 0, truck: 0 } }), pan !== false && _jsx(Pan, { ...optionsOf(pan) }), zoom !== false && _jsx(Zoom, { ...optionsOf(zoom) }), roll !== false && _jsx(Roll, { ...optionsOf(roll) }), pitch !== false && _jsx(Pitch, {})] }));
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* The options a handler was configured with, if it was given any rather than a bare flag.
|
|
@@ -30,9 +30,10 @@ export interface GestureContext {
|
|
|
30
30
|
* **Two fingers are why any of this exists.** camera-controls recognizes a pinch and an orbit, but
|
|
31
31
|
* neither of the two-finger gestures the plan needs: a twist that turns the plan about the point
|
|
32
32
|
* between the fingers, and a vertical drag that tilts it. Both come off `mjolnir.js`, whose
|
|
33
|
-
* recognizer configuration is the imperative renderer's verbatim
|
|
33
|
+
* recognizer configuration is the imperative renderer's verbatim — as does the double tap that
|
|
34
|
+
* `Zoom` zooms on, which camera-controls does not recognize either.
|
|
34
35
|
*
|
|
35
|
-
* The recognizer itself is shared: `Roll` and `
|
|
36
|
+
* The recognizer itself is shared: `Roll`, `Pitch` and `Zoom` want one on the same canvas, and two would
|
|
36
37
|
* mean two sets of pointer listeners racing over the same fingers. It is built on the first
|
|
37
38
|
* subscription and torn down with the last, since it attaches its listeners in its constructor and
|
|
38
39
|
* offers no disconnect. Every recognizer stays dormant until something subscribes to its event, so
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useThree } from '@react-three/fiber';
|
|
2
|
-
import { EventManager, Pan, Rotate } from 'mjolnir.js';
|
|
2
|
+
import { EventManager, Pan, Rotate, Tap } from 'mjolnir.js';
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
/**
|
|
5
5
|
* Subscribes to one of the canvas's gestures for as long as the component is mounted.
|
|
@@ -7,9 +7,10 @@ import { useEffect } from 'react';
|
|
|
7
7
|
* **Two fingers are why any of this exists.** camera-controls recognizes a pinch and an orbit, but
|
|
8
8
|
* neither of the two-finger gestures the plan needs: a twist that turns the plan about the point
|
|
9
9
|
* between the fingers, and a vertical drag that tilts it. Both come off `mjolnir.js`, whose
|
|
10
|
-
* recognizer configuration is the imperative renderer's verbatim
|
|
10
|
+
* recognizer configuration is the imperative renderer's verbatim — as does the double tap that
|
|
11
|
+
* `Zoom` zooms on, which camera-controls does not recognize either.
|
|
11
12
|
*
|
|
12
|
-
* The recognizer itself is shared: `Roll` and `
|
|
13
|
+
* The recognizer itself is shared: `Roll`, `Pitch` and `Zoom` want one on the same canvas, and two would
|
|
13
14
|
* mean two sets of pointer listeners racing over the same fingers. It is built on the first
|
|
14
15
|
* subscription and torn down with the last, since it attaches its listeners in its constructor and
|
|
15
16
|
* offers no disconnect. Every recognizer stays dormant until something subscribes to its event, so
|
|
@@ -46,7 +47,15 @@ export function acquireGestureManager(element) {
|
|
|
46
47
|
return held.manager;
|
|
47
48
|
}
|
|
48
49
|
const manager = new EventManager(element, {
|
|
49
|
-
recognizers: [
|
|
50
|
+
recognizers: [
|
|
51
|
+
Rotate,
|
|
52
|
+
[Pan, { event: 'pitch', pointers: 2 }, 'rotate'],
|
|
53
|
+
// A mouse's pointer events feed the recognizer as a finger's do, so one double tap is both
|
|
54
|
+
// the double-click and the double-tap. MapLibre's tap tolerances rather than the
|
|
55
|
+
// recognizer's own: a finger's second tap rarely lands within 10px of its first, and with
|
|
56
|
+
// the default `posThreshold` a real double tap on a phone was never recognized.
|
|
57
|
+
[Tap, { event: 'doubletap', taps: 2, interval: 500, time: 500, posThreshold: 30 }],
|
|
58
|
+
],
|
|
50
59
|
});
|
|
51
60
|
managers.set(element, { manager, refs: 1 });
|
|
52
61
|
return manager;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Props for {@link DoubleTapZoom}. */
|
|
2
|
+
export interface DoubleTapZoomProps {
|
|
3
|
+
/** What a double tap multiplies the zoom factor by. */
|
|
4
|
+
factor: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A double-click or double-tap: zooms by `factor` about the point under the pointer, which keeps
|
|
8
|
+
* its place on the canvas throughout.
|
|
9
|
+
*
|
|
10
|
+
* Mounted by `Zoom`, and only when a double-click is meant to zoom. One double tap is both the
|
|
11
|
+
* double-click and the double-tap: the gesture recognizer reads a mouse's pointer events as it
|
|
12
|
+
* reads a finger's.
|
|
13
|
+
*
|
|
14
|
+
* Run as a user transition, so it eases the way a wheel tick does — by `controlSmoothTime`, with the
|
|
15
|
+
* target on the dolly's clock — and reports itself as the user's hand.
|
|
16
|
+
*
|
|
17
|
+
* A component of its own rather than a branch inside `Zoom`, because subscribing is a hook and a
|
|
18
|
+
* hook cannot be switched off conditionally — mounting this is what switches it on.
|
|
19
|
+
* @param props - The zoom factor to multiply by.
|
|
20
|
+
* @returns Nothing; this renders no scene node.
|
|
21
|
+
*/
|
|
22
|
+
export declare function DoubleTapZoom(props: DoubleTapZoomProps): null;
|
|
23
|
+
//# sourceMappingURL=DoubleTapZoom.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useThree } from '@react-three/fiber';
|
|
2
|
+
import { Spherical, Vector3 } from 'three';
|
|
3
|
+
import { useSpace, } from '../../space/Space';
|
|
4
|
+
import { clamp } from '../../utils/math';
|
|
5
|
+
import { useGesture } from '../gestures';
|
|
6
|
+
import { useCameraControls } from '../hooks';
|
|
7
|
+
/**
|
|
8
|
+
* A double-click or double-tap: zooms by `factor` about the point under the pointer, which keeps
|
|
9
|
+
* its place on the canvas throughout.
|
|
10
|
+
*
|
|
11
|
+
* Mounted by `Zoom`, and only when a double-click is meant to zoom. One double tap is both the
|
|
12
|
+
* double-click and the double-tap: the gesture recognizer reads a mouse's pointer events as it
|
|
13
|
+
* reads a finger's.
|
|
14
|
+
*
|
|
15
|
+
* Run as a user transition, so it eases the way a wheel tick does — by `controlSmoothTime`, with the
|
|
16
|
+
* target on the dolly's clock — and reports itself as the user's hand.
|
|
17
|
+
*
|
|
18
|
+
* A component of its own rather than a branch inside `Zoom`, because subscribing is a hook and a
|
|
19
|
+
* hook cannot be switched off conditionally — mounting this is what switches it on.
|
|
20
|
+
* @param props - The zoom factor to multiply by.
|
|
21
|
+
* @returns Nothing; this renders no scene node.
|
|
22
|
+
*/
|
|
23
|
+
export function DoubleTapZoom(props) {
|
|
24
|
+
const { factor } = props;
|
|
25
|
+
const get = useThree((state) => state.get);
|
|
26
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
27
|
+
const space = useSpace();
|
|
28
|
+
// Read, not written: `userTransition` takes the move as a callback, which does the writing.
|
|
29
|
+
const controls = useCameraControls();
|
|
30
|
+
const onDoubleTap = (event) => {
|
|
31
|
+
if (!controls)
|
|
32
|
+
return;
|
|
33
|
+
// mjolnir measures the tap from the target's own padding edge, as `canvasPoint` does.
|
|
34
|
+
const around = space.canvasToPlan(event.offsetCenter, get().camera);
|
|
35
|
+
if (!around)
|
|
36
|
+
return;
|
|
37
|
+
void controls.userTransition(() => zoomAbout(controls, space, factor, around));
|
|
38
|
+
// The move happens outside a React commit, so a demand frame loop has to be asked for a frame.
|
|
39
|
+
invalidate();
|
|
40
|
+
};
|
|
41
|
+
useGesture('doubletap', onDoubleTap);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Zooms by a factor about a point on the plan, which keeps its place on the canvas.
|
|
46
|
+
*
|
|
47
|
+
* Scaling the whole camera about the point, orientation untouched, is what leaves the point where
|
|
48
|
+
* it was: the target slides toward it in the proportion the distance shrinks. Exact under a visible
|
|
49
|
+
* rect too, whose offset rides in the projection this leaves alone. It holds **during** the ease
|
|
50
|
+
* only inside a user transition, which puts the target on the dolly's clock.
|
|
51
|
+
*
|
|
52
|
+
* **Scaled from where the camera is now, sized from where it is heading.** A double tap often lands
|
|
53
|
+
* while the last one is still easing. The point under the finger is found through the camera as it
|
|
54
|
+
* is drawn, so it is the camera's current stance that has to be scaled about it — that keeps the
|
|
55
|
+
* point under the finger from here on. The zoom is sized from the distance the camera is heading
|
|
56
|
+
* to, so a second double tap zooms a full step past the first rather than a step past wherever the
|
|
57
|
+
* first had got to, as successive wheel notches do. The clamped distance sets the proportion, so a
|
|
58
|
+
* zoom stopped short by the camera's limits does not slide the target as far as the one asked for.
|
|
59
|
+
* @param controls - The controls to move.
|
|
60
|
+
* @param space - The scene's space.
|
|
61
|
+
* @param factor - What to multiply the zoom factor by.
|
|
62
|
+
* @param around - The point to zoom about, in plan units.
|
|
63
|
+
* @returns When the move has settled.
|
|
64
|
+
*/
|
|
65
|
+
function zoomAbout(controls, space, factor, around) {
|
|
66
|
+
const from = controls.distance;
|
|
67
|
+
// The zoom factor is inversely proportional to the distance.
|
|
68
|
+
const to = clamp(controls.getSpherical(_spherical, true).radius / factor, controls.minDistance, controls.maxDistance);
|
|
69
|
+
const pivot = space.planToWorld(around, _pivot);
|
|
70
|
+
const target = controls
|
|
71
|
+
.getTarget(_target, false)
|
|
72
|
+
.sub(pivot)
|
|
73
|
+
.multiplyScalar(to / from)
|
|
74
|
+
.add(pivot);
|
|
75
|
+
return Promise.all([
|
|
76
|
+
controls.moveTo(target.x, target.y, target.z, true),
|
|
77
|
+
controls.dollyTo(to, true),
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
const _pivot = new Vector3();
|
|
81
|
+
const _target = new Vector3();
|
|
82
|
+
const _spherical = new Spherical();
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How the glide a
|
|
2
|
+
* How the glide a motion leaves behind is measured, and how it slows.
|
|
3
3
|
*
|
|
4
|
-
* The speed is taken from
|
|
5
|
-
*
|
|
4
|
+
* The speed is taken from the motion's own samples over the last stretch of the interaction, so
|
|
5
|
+
* the threshold is in the motion's units per millisecond: css pixels for a pan.
|
|
6
6
|
*/
|
|
7
7
|
export interface InertiaConfig {
|
|
8
8
|
/**
|
|
9
|
-
* How far back the
|
|
10
|
-
* oldest sample at least this old is the one differenced against, so a flick that ended
|
|
11
|
-
* stall glides at the stall's speed rather than the flick's.
|
|
9
|
+
* How far back the interaction is sampled to measure its speed, in milliseconds; defaults to
|
|
10
|
+
* `50`. The oldest sample at least this old is the one differenced against, so a flick that ended
|
|
11
|
+
* in a stall glides at the stall's speed rather than the flick's.
|
|
12
12
|
*/
|
|
13
13
|
minSampleMs?: number;
|
|
14
14
|
/** How long the glide lasts, in milliseconds; defaults to `1000`. */
|
|
15
15
|
durationMs?: number;
|
|
16
16
|
/**
|
|
17
|
-
* The speed below which a lifted pointer leaves no glide at all, in
|
|
18
|
-
* defaults to `0.08
|
|
17
|
+
* The speed below which a lifted pointer leaves no glide at all, in the motion's units per
|
|
18
|
+
* millisecond; defaults to `0.08`, sized for a pan in css pixels.
|
|
19
19
|
*/
|
|
20
20
|
speedThreshold?: number;
|
|
21
21
|
/**
|
|
@@ -27,19 +27,34 @@ export interface InertiaConfig {
|
|
|
27
27
|
*/
|
|
28
28
|
ease?: (t: number) => number;
|
|
29
29
|
}
|
|
30
|
+
/** Props for {@link Inertia}: the motion it carries on, and how. */
|
|
31
|
+
export interface InertiaProps extends InertiaConfig {
|
|
32
|
+
/**
|
|
33
|
+
* How far the motion moved since the last call, or `undefined` when this step of the interaction
|
|
34
|
+
* was not one of its own. Called on every `control` event the controls report, so it measures the
|
|
35
|
+
* motion directly rather than inferring it from where the camera went.
|
|
36
|
+
*/
|
|
37
|
+
sample: () => readonly number[] | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Moves the camera by one step of the glide, in the same units as the samples.
|
|
40
|
+
* @returns Whether it moved; `false` ends the glide.
|
|
41
|
+
*/
|
|
42
|
+
apply: (step: readonly number[]) => boolean;
|
|
43
|
+
}
|
|
30
44
|
/**
|
|
31
|
-
* The glide a lifted pointer leaves behind.
|
|
45
|
+
* The glide a lifted pointer leaves behind, for one kind of motion.
|
|
32
46
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
47
|
+
* MapLibre's shape: each kind of motion measures itself while the user drives it, and when the
|
|
48
|
+
* interaction ends it glides on at the speed it ended with — rather than one glide reading the
|
|
49
|
+
* camera after the fact, where a pan, a pinch's slide toward the fingers and a twist's turn about
|
|
50
|
+
* them all arrive mixed into the same target. Generic over the motion: the caller says how to
|
|
51
|
+
* sample it and how to apply a step of it, and this owns the timing.
|
|
36
52
|
*
|
|
37
53
|
* Runs its frame callback **ahead of the controls' own** (see {@link INERTIA_PRIORITY}), so the
|
|
38
|
-
*
|
|
39
|
-
* imperative renderer, which updated the controller before its handlers. It asks for the next frame
|
|
54
|
+
* camera it moves is the one that frame draws rather than the next. It asks for the next frame
|
|
40
55
|
* itself, which is what keeps a glide alive under the on-demand loop.
|
|
41
|
-
* @param props -
|
|
56
|
+
* @param props - What the glide carries on, and how it is measured and slows.
|
|
42
57
|
* @returns Nothing; this renders no scene node.
|
|
43
58
|
*/
|
|
44
|
-
export declare function Inertia(props:
|
|
59
|
+
export declare function Inertia(props: InertiaProps): null;
|
|
45
60
|
//# sourceMappingURL=Inertia.d.ts.map
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { useFrame, useThree } from '@react-three/fiber';
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
|
-
import { Vector3 } from 'three';
|
|
4
3
|
import { useControlsEffect } from '../hooks';
|
|
5
4
|
/**
|
|
6
|
-
* The glide a lifted pointer leaves behind.
|
|
5
|
+
* The glide a lifted pointer leaves behind, for one kind of motion.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* MapLibre's shape: each kind of motion measures itself while the user drives it, and when the
|
|
8
|
+
* interaction ends it glides on at the speed it ended with — rather than one glide reading the
|
|
9
|
+
* camera after the fact, where a pan, a pinch's slide toward the fingers and a twist's turn about
|
|
10
|
+
* them all arrive mixed into the same target. Generic over the motion: the caller says how to
|
|
11
|
+
* sample it and how to apply a step of it, and this owns the timing.
|
|
11
12
|
*
|
|
12
13
|
* Runs its frame callback **ahead of the controls' own** (see {@link INERTIA_PRIORITY}), so the
|
|
13
|
-
*
|
|
14
|
-
* imperative renderer, which updated the controller before its handlers. It asks for the next frame
|
|
14
|
+
* camera it moves is the one that frame draws rather than the next. It asks for the next frame
|
|
15
15
|
* itself, which is what keeps a glide alive under the on-demand loop.
|
|
16
|
-
* @param props -
|
|
16
|
+
* @param props - What the glide carries on, and how it is measured and slows.
|
|
17
17
|
* @returns Nothing; this renders no scene node.
|
|
18
18
|
*/
|
|
19
19
|
export function Inertia(props) {
|
|
@@ -21,13 +21,15 @@ export function Inertia(props) {
|
|
|
21
21
|
const durationMs = props.durationMs ?? DEFAULT_DURATION_MS;
|
|
22
22
|
const speedThreshold = props.speedThreshold ?? DEFAULT_SPEED_THRESHOLD;
|
|
23
23
|
const ease = props.ease ?? easeInertia;
|
|
24
|
+
const { sample, apply } = props;
|
|
24
25
|
const invalidate = useThree((state) => state.invalidate);
|
|
25
|
-
const [inertia] = useState(() => new InertiaController({ minSampleMs, durationMs, speedThreshold, ease }));
|
|
26
|
-
// Assignments only, so a caller passing an inline `ease` re-syncs
|
|
27
|
-
// nothing else — in particular it never re-subscribes, which is why this is its own
|
|
26
|
+
const [inertia] = useState(() => new InertiaController({ minSampleMs, durationMs, speedThreshold, ease }, sample, apply));
|
|
27
|
+
// Assignments only, so a caller passing an inline `ease` or a fresh `sample` re-syncs fields and
|
|
28
|
+
// disturbs nothing else — in particular it never re-subscribes, which is why this is its own
|
|
29
|
+
// effect.
|
|
28
30
|
useEffect(() => {
|
|
29
|
-
inertia.configure({ minSampleMs, durationMs, speedThreshold, ease });
|
|
30
|
-
}, [inertia, minSampleMs, durationMs, speedThreshold, ease]);
|
|
31
|
+
inertia.configure({ minSampleMs, durationMs, speedThreshold, ease }, sample, apply);
|
|
32
|
+
}, [inertia, minSampleMs, durationMs, speedThreshold, ease, sample, apply]);
|
|
31
33
|
useControlsEffect((controls) => {
|
|
32
34
|
inertia.connect(controls);
|
|
33
35
|
return () => inertia.disconnect();
|
|
@@ -48,59 +50,65 @@ function easeInertia(t) {
|
|
|
48
50
|
return Math.pow(2, 10 * (t - 1));
|
|
49
51
|
}
|
|
50
52
|
/**
|
|
51
|
-
* Carries a
|
|
53
|
+
* Carries a motion on after the pointer lifts.
|
|
52
54
|
*
|
|
53
|
-
* Framework-free on purpose: everything here is arithmetic over
|
|
55
|
+
* Framework-free on purpose: everything here is arithmetic over a running total and a list of
|
|
54
56
|
* timestamps, which is the half worth testing without a canvas around it. {@link Inertia} is the
|
|
55
57
|
* React half — when to connect, when to configure, when to step.
|
|
56
58
|
*
|
|
57
|
-
* **The speed comes from
|
|
58
|
-
*
|
|
59
|
-
* diffed against the
|
|
60
|
-
* stall's speed, because the
|
|
59
|
+
* **The speed comes from the motion's own samples.** Each `control` event adds the motion's delta
|
|
60
|
+
* to a running total and records it; when the pointer lifts, the oldest record at least
|
|
61
|
+
* `minSampleMs` old is diffed against the total. A flick that ended in a stall therefore glides at
|
|
62
|
+
* the stall's speed, because the records covering the stall are the recent ones. A step the motion
|
|
63
|
+
* sat out adds nothing, so an interaction it never took part in glides nowhere.
|
|
61
64
|
*/
|
|
62
65
|
class InertiaController {
|
|
63
66
|
minSampleMs;
|
|
64
67
|
durationMs;
|
|
65
68
|
speedThreshold;
|
|
66
69
|
ease;
|
|
70
|
+
sample;
|
|
71
|
+
apply;
|
|
67
72
|
controls;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
total = [];
|
|
74
|
+
records = [];
|
|
75
|
+
velocity = [];
|
|
76
|
+
step = [];
|
|
72
77
|
active = false;
|
|
73
78
|
elapsedMs = 0;
|
|
74
79
|
prevFactor = 1;
|
|
75
80
|
/**
|
|
76
|
-
* Takes every value rather than defaulting any: what an omitted option means is
|
|
81
|
+
* Takes every value rather than defaulting any: what an omitted option means is {@link Inertia}'s
|
|
77
82
|
* to answer, and answering it twice is how the two drift apart.
|
|
78
83
|
* @param options - How the glide is measured and how it slows.
|
|
84
|
+
* @param sample - How far the motion moved since the last call.
|
|
85
|
+
* @param apply - Moves the camera by one step of the glide.
|
|
79
86
|
*/
|
|
80
|
-
constructor(options) {
|
|
87
|
+
constructor(options, sample, apply) {
|
|
81
88
|
this.minSampleMs = options.minSampleMs;
|
|
82
89
|
this.durationMs = options.durationMs;
|
|
83
90
|
this.speedThreshold = options.speedThreshold;
|
|
84
91
|
this.ease = options.ease;
|
|
92
|
+
this.sample = sample;
|
|
93
|
+
this.apply = apply;
|
|
85
94
|
}
|
|
86
95
|
/**
|
|
87
|
-
* Sets how the glide is measured and
|
|
88
|
-
*
|
|
89
|
-
* @param
|
|
96
|
+
* Sets the motion, and how the glide is measured and slows.
|
|
97
|
+
* @param options - How the glide is measured and how it slows.
|
|
98
|
+
* @param sample - How far the motion moved since the last call.
|
|
99
|
+
* @param apply - Moves the camera by one step of the glide.
|
|
90
100
|
*/
|
|
91
|
-
configure(options) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (options.ease !== undefined)
|
|
99
|
-
this.ease = options.ease;
|
|
101
|
+
configure(options, sample, apply) {
|
|
102
|
+
this.minSampleMs = options.minSampleMs;
|
|
103
|
+
this.durationMs = options.durationMs;
|
|
104
|
+
this.speedThreshold = options.speedThreshold;
|
|
105
|
+
this.ease = options.ease;
|
|
106
|
+
this.sample = sample;
|
|
107
|
+
this.apply = apply;
|
|
100
108
|
}
|
|
101
109
|
/**
|
|
102
110
|
* Starts watching a controls instance for the interactions a glide follows.
|
|
103
|
-
* @param controls - The controls to watch
|
|
111
|
+
* @param controls - The controls to watch.
|
|
104
112
|
*/
|
|
105
113
|
connect(controls) {
|
|
106
114
|
if (this.controls)
|
|
@@ -116,7 +124,7 @@ class InertiaController {
|
|
|
116
124
|
if (!controls)
|
|
117
125
|
return;
|
|
118
126
|
this.active = false;
|
|
119
|
-
this.
|
|
127
|
+
this.records.length = 0;
|
|
120
128
|
controls.removeEventListener('controlstart', this.onControlStart);
|
|
121
129
|
controls.removeEventListener('control', this.onControl);
|
|
122
130
|
controls.removeEventListener('controlend', this.onControlEnd);
|
|
@@ -132,8 +140,7 @@ class InertiaController {
|
|
|
132
140
|
* @returns Whether the camera moved, and so whether another frame is wanted.
|
|
133
141
|
*/
|
|
134
142
|
update(delta) {
|
|
135
|
-
|
|
136
|
-
if (!this.active || !controls)
|
|
143
|
+
if (!this.active)
|
|
137
144
|
return false;
|
|
138
145
|
const deltaMs = delta * 1000;
|
|
139
146
|
this.elapsedMs += deltaMs;
|
|
@@ -141,54 +148,54 @@ class InertiaController {
|
|
|
141
148
|
const factor = this.ease(1 - t); // 1 -> 0
|
|
142
149
|
const avgFactor = (factor + this.prevFactor) * 0.5;
|
|
143
150
|
this.prevFactor = factor;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// imperative renderer's parallel arithmetic on the camera position was never read back.
|
|
152
|
-
void controls.moveTo(this.target.x, this.target.y, this.target.z, false);
|
|
151
|
+
for (let i = 0; i < this.velocity.length; i++) {
|
|
152
|
+
this.step[i] = this.velocity[i] * avgFactor * deltaMs;
|
|
153
|
+
}
|
|
154
|
+
if (!this.apply(this.step)) {
|
|
155
|
+
this.active = false;
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
153
158
|
if (factor <= REST_FACTOR || t >= 1)
|
|
154
159
|
this.active = false;
|
|
155
160
|
return true;
|
|
156
161
|
}
|
|
157
162
|
onControlStart = () => {
|
|
158
163
|
this.active = false;
|
|
159
|
-
this.
|
|
164
|
+
this.records.length = 0;
|
|
165
|
+
this.total.fill(0);
|
|
160
166
|
};
|
|
161
167
|
onControl = () => {
|
|
162
|
-
const
|
|
163
|
-
if (!controls)
|
|
164
|
-
return;
|
|
168
|
+
const delta = this.sample();
|
|
165
169
|
const now = performance.now();
|
|
166
|
-
|
|
167
|
-
|
|
170
|
+
if (delta) {
|
|
171
|
+
for (let i = 0; i < delta.length; i++)
|
|
172
|
+
this.total[i] = (this.total[i] ?? 0) + delta[i];
|
|
173
|
+
}
|
|
174
|
+
this.records.push({ t: now, total: this.total.slice() });
|
|
168
175
|
const cutoff = now - SAMPLE_WINDOW_MS;
|
|
169
|
-
while (this.
|
|
170
|
-
this.
|
|
176
|
+
while (this.records.length && this.records[0].t < cutoff)
|
|
177
|
+
this.records.shift();
|
|
171
178
|
};
|
|
172
179
|
onControlEnd = () => {
|
|
173
|
-
const controls = this.controls;
|
|
174
|
-
if (!controls)
|
|
175
|
-
return;
|
|
176
180
|
const now = performance.now();
|
|
177
181
|
const oldest = now - this.minSampleMs;
|
|
178
|
-
for (let i = this.
|
|
179
|
-
const
|
|
180
|
-
// The first
|
|
181
|
-
//
|
|
182
|
-
if (
|
|
182
|
+
for (let i = this.records.length - 1; i >= 0; i--) {
|
|
183
|
+
const record = this.records[i];
|
|
184
|
+
// The first record old enough to measure against — or the oldest there is, when the whole
|
|
185
|
+
// interaction was shorter than the sampling window.
|
|
186
|
+
if (record.t >= oldest && i !== 0)
|
|
183
187
|
continue;
|
|
184
|
-
const dt = now -
|
|
188
|
+
const dt = now - record.t;
|
|
185
189
|
if (dt <= 0)
|
|
186
190
|
return;
|
|
187
|
-
|
|
188
|
-
this.velocity.
|
|
189
|
-
|
|
190
|
-
this.
|
|
191
|
-
|
|
191
|
+
let speedSquared = 0;
|
|
192
|
+
this.velocity.length = this.total.length;
|
|
193
|
+
this.step.length = this.total.length;
|
|
194
|
+
for (let axis = 0; axis < this.total.length; axis++) {
|
|
195
|
+
this.velocity[axis] = (this.total[axis] - (record.total[axis] ?? 0)) / dt;
|
|
196
|
+
speedSquared += this.velocity[axis] * this.velocity[axis];
|
|
197
|
+
}
|
|
198
|
+
if (Math.sqrt(speedSquared) <= this.speedThreshold)
|
|
192
199
|
return;
|
|
193
200
|
this.elapsedMs = 0;
|
|
194
201
|
this.prevFactor = 1;
|
|
@@ -203,13 +210,13 @@ class InertiaController {
|
|
|
203
210
|
* Negative on both counts, so neither takes over rendering from R3F — a positive priority would.
|
|
204
211
|
*/
|
|
205
212
|
const INERTIA_PRIORITY = -2;
|
|
206
|
-
/** How much of
|
|
213
|
+
/** How much of an interaction is kept to measure its speed from, in milliseconds. */
|
|
207
214
|
const SAMPLE_WINDOW_MS = 300;
|
|
208
215
|
/** The easing factor below which what is left of a glide is not worth another frame. */
|
|
209
216
|
const REST_FACTOR = 0.02;
|
|
210
|
-
/** How far back
|
|
217
|
+
/** How far back an interaction is sampled to measure the speed it should glide at, in milliseconds. */
|
|
211
218
|
const DEFAULT_SAMPLE_MS = 50;
|
|
212
219
|
/** How long a glide lasts, in milliseconds. */
|
|
213
220
|
const DEFAULT_DURATION_MS = 1000;
|
|
214
|
-
/** The speed below which a lifted pointer leaves no glide, in
|
|
221
|
+
/** The speed below which a lifted pointer leaves no glide, in the motion's units per millisecond. */
|
|
215
222
|
const DEFAULT_SPEED_THRESHOLD = 0.08;
|
|
@@ -12,7 +12,7 @@ export interface PanConfig {
|
|
|
12
12
|
* along the camera's own axes — the imperative renderer's choice, and what makes a tilted plan drag
|
|
13
13
|
* the way a map does.
|
|
14
14
|
*
|
|
15
|
-
* Owns the glide a lifted pointer leaves by rendering {@link
|
|
15
|
+
* Owns the glide a lifted pointer leaves by rendering {@link PanInertia}, which is the one piece of
|
|
16
16
|
* panning camera-controls does not do itself.
|
|
17
17
|
*
|
|
18
18
|
* Mounting is enabling: the actions are set on mount and returned to `NONE` on unmount.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Point } from '@expofp/geometry';
|
|
3
|
+
import { useThree } from '@react-three/fiber';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { Vector3 } from 'three';
|
|
6
|
+
import { useSpace, } from '../../space/Space';
|
|
2
7
|
import { CameraControlsAction } from '../CameraControls';
|
|
3
|
-
import {
|
|
8
|
+
import { canvasPoint } from '../gestures';
|
|
9
|
+
import { useCameraControls, useControlsEffect } from '../hooks';
|
|
4
10
|
import { Inertia } from './Inertia';
|
|
5
11
|
/**
|
|
6
12
|
* Panning: a left-drag, and a one-finger drag.
|
|
@@ -9,7 +15,7 @@ import { Inertia } from './Inertia';
|
|
|
9
15
|
* along the camera's own axes — the imperative renderer's choice, and what makes a tilted plan drag
|
|
10
16
|
* the way a map does.
|
|
11
17
|
*
|
|
12
|
-
* Owns the glide a lifted pointer leaves by rendering {@link
|
|
18
|
+
* Owns the glide a lifted pointer leaves by rendering {@link PanInertia}, which is the one piece of
|
|
13
19
|
* panning camera-controls does not do itself.
|
|
14
20
|
*
|
|
15
21
|
* Mounting is enabling: the actions are set on mount and returned to `NONE` on unmount.
|
|
@@ -28,5 +34,179 @@ export function Pan(props) {
|
|
|
28
34
|
});
|
|
29
35
|
if (inertia === false)
|
|
30
36
|
return null;
|
|
31
|
-
return _jsx(
|
|
37
|
+
return _jsx(PanInertia, { ...(inertia === true ? {} : inertia) });
|
|
32
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The glide a pan leaves behind: {@link Inertia}, fed the pan itself.
|
|
41
|
+
*
|
|
42
|
+
* **Measured from the pointers, not from the camera.** Every step of a drag that pans — by mouse,
|
|
43
|
+
* by one finger, or by two fingers pinching and panning at once — samples how far the pointers'
|
|
44
|
+
* centroid moved, in css pixels. That is the pan camera-controls applies on the same step, and
|
|
45
|
+
* nothing else: a pinch spreads the fingers about their centroid, and a twist turns them about it,
|
|
46
|
+
* so neither moves it, and neither leaks into the glide. A step whose action has no pan in it — a
|
|
47
|
+
* right-drag turn, or a tilt, which switches the two-finger action off — samples nothing.
|
|
48
|
+
*
|
|
49
|
+
* Applied the same way round: each step of the glide moves the plan so that the point under the
|
|
50
|
+
* last centroid travels the step's pixels, which glides a tilted or turned plan the way the drag
|
|
51
|
+
* moved it.
|
|
52
|
+
*
|
|
53
|
+
* A component of its own rather than a branch inside {@link Pan}, because its hooks cannot be
|
|
54
|
+
* switched off conditionally — mounting this is what switches the glide on.
|
|
55
|
+
* @param props - How the glide is measured and how it slows.
|
|
56
|
+
* @returns The glide.
|
|
57
|
+
*/
|
|
58
|
+
function PanInertia(props) {
|
|
59
|
+
const canvas = useThree((state) => state.gl.domElement);
|
|
60
|
+
const get = useThree((state) => state.get);
|
|
61
|
+
const space = useSpace();
|
|
62
|
+
// Read, not written: `panBy` takes the controls as an argument and does the writing.
|
|
63
|
+
const controls = useCameraControls();
|
|
64
|
+
const [pointers] = useState(() => new PointerCentroid());
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
pointers.connect(canvas);
|
|
67
|
+
return () => pointers.disconnect();
|
|
68
|
+
}, [pointers, canvas]);
|
|
69
|
+
const sample = () => {
|
|
70
|
+
// Always taken, so a step that sat the pan out does not leave its movement for the next one.
|
|
71
|
+
const delta = pointers.sample();
|
|
72
|
+
return controls && (controls.currentAction & PAN) !== 0 ? delta : undefined;
|
|
73
|
+
};
|
|
74
|
+
const apply = (step) => controls !== undefined && panBy(controls, space, get().camera, pointers.anchor, step);
|
|
75
|
+
return _jsx(Inertia, { ...props, sample: sample, apply: apply });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Where the pointers are, and how far their centroid has moved since the last sample.
|
|
79
|
+
*
|
|
80
|
+
* Measured the way MapLibre's touch pan measures it: as the average movement of the pointers that
|
|
81
|
+
* were down at both samples. A finger that lands or lifts between two samples therefore adds no
|
|
82
|
+
* jump of its own, where the raw centroid would leap halfway to it.
|
|
83
|
+
*/
|
|
84
|
+
class PointerCentroid {
|
|
85
|
+
/** Where the pointers' centroid was at the last sample, in css pixels from the canvas's corner. */
|
|
86
|
+
anchor = new Point();
|
|
87
|
+
element;
|
|
88
|
+
positions = new Map();
|
|
89
|
+
last = new Map();
|
|
90
|
+
delta = [0, 0];
|
|
91
|
+
/**
|
|
92
|
+
* Starts tracking the pointers that land on an element. Moves and lifts are heard on its
|
|
93
|
+
* document, as camera-controls hears them, so a mouse dragged off the canvas is still followed.
|
|
94
|
+
* @param element - The element pointers land on.
|
|
95
|
+
*/
|
|
96
|
+
connect(element) {
|
|
97
|
+
if (this.element)
|
|
98
|
+
this.disconnect();
|
|
99
|
+
this.element = element;
|
|
100
|
+
const document = element.ownerDocument;
|
|
101
|
+
element.addEventListener('pointerdown', this.onDown);
|
|
102
|
+
document.addEventListener('pointermove', this.onMove);
|
|
103
|
+
document.addEventListener('pointerup', this.onUp);
|
|
104
|
+
document.addEventListener('pointercancel', this.onUp);
|
|
105
|
+
}
|
|
106
|
+
/** Stops tracking, and forgets every pointer. */
|
|
107
|
+
disconnect() {
|
|
108
|
+
const element = this.element;
|
|
109
|
+
if (!element)
|
|
110
|
+
return;
|
|
111
|
+
const document = element.ownerDocument;
|
|
112
|
+
element.removeEventListener('pointerdown', this.onDown);
|
|
113
|
+
document.removeEventListener('pointermove', this.onMove);
|
|
114
|
+
document.removeEventListener('pointerup', this.onUp);
|
|
115
|
+
document.removeEventListener('pointercancel', this.onUp);
|
|
116
|
+
this.positions.clear();
|
|
117
|
+
this.last.clear();
|
|
118
|
+
this.element = undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* How far the centroid moved since the last call, and where it is now.
|
|
122
|
+
* @returns The movement in css pixels, as `[x, y]`; reused between calls.
|
|
123
|
+
*/
|
|
124
|
+
sample() {
|
|
125
|
+
let dx = 0;
|
|
126
|
+
let dy = 0;
|
|
127
|
+
let moved = 0;
|
|
128
|
+
let cx = 0;
|
|
129
|
+
let cy = 0;
|
|
130
|
+
for (const [id, position] of this.positions) {
|
|
131
|
+
cx += position.x;
|
|
132
|
+
cy += position.y;
|
|
133
|
+
const last = this.last.get(id);
|
|
134
|
+
if (!last)
|
|
135
|
+
continue;
|
|
136
|
+
dx += position.x - last.x;
|
|
137
|
+
dy += position.y - last.y;
|
|
138
|
+
last.x = position.x;
|
|
139
|
+
last.y = position.y;
|
|
140
|
+
moved++;
|
|
141
|
+
}
|
|
142
|
+
if (this.positions.size)
|
|
143
|
+
this.anchor.set(cx / this.positions.size, cy / this.positions.size, 0);
|
|
144
|
+
this.delta[0] = moved ? dx / moved : 0;
|
|
145
|
+
this.delta[1] = moved ? dy / moved : 0;
|
|
146
|
+
return this.delta;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A pointer landing on the canvas, whose `offsetX` is measured from the canvas itself. Its later
|
|
150
|
+
* moves are heard on the document and may target any element, so their own `offsetX` is not the
|
|
151
|
+
* canvas's — they are placed by how far they moved in client pixels from where this one landed.
|
|
152
|
+
* @param event - The pointer landing.
|
|
153
|
+
*/
|
|
154
|
+
onDown = (event) => {
|
|
155
|
+
const { x, y } = canvasPoint(event);
|
|
156
|
+
this.positions.set(event.pointerId, {
|
|
157
|
+
x,
|
|
158
|
+
y,
|
|
159
|
+
toCanvasX: x - event.clientX,
|
|
160
|
+
toCanvasY: y - event.clientY,
|
|
161
|
+
});
|
|
162
|
+
this.last.set(event.pointerId, { x, y });
|
|
163
|
+
};
|
|
164
|
+
onMove = (event) => {
|
|
165
|
+
const position = this.positions.get(event.pointerId);
|
|
166
|
+
if (!position)
|
|
167
|
+
return;
|
|
168
|
+
position.x = event.clientX + position.toCanvasX;
|
|
169
|
+
position.y = event.clientY + position.toCanvasY;
|
|
170
|
+
};
|
|
171
|
+
onUp = (event) => {
|
|
172
|
+
this.positions.delete(event.pointerId);
|
|
173
|
+
this.last.delete(event.pointerId);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Pans the plan by a step in css pixels: the point under `anchor` moves to `anchor + step`.
|
|
178
|
+
*
|
|
179
|
+
* Both points are found on the plan through the camera and taken out to the world, and the target
|
|
180
|
+
* moves by their difference — so a tilted plan, whose pixels cover more ground toward the horizon,
|
|
181
|
+
* glides as far as the drag would have carried it there.
|
|
182
|
+
* @param controls - The controls to move.
|
|
183
|
+
* @param space - The scene's space.
|
|
184
|
+
* @param camera - The camera the canvas is drawn through.
|
|
185
|
+
* @param anchor - Where the step starts, in css pixels from the canvas's top-left corner.
|
|
186
|
+
* @param step - How far to move, in css pixels, as `[x, y]`.
|
|
187
|
+
* @returns Whether it moved; a step off the plan, toward the horizon, does not.
|
|
188
|
+
*/
|
|
189
|
+
function panBy(controls, space, camera, anchor, step) {
|
|
190
|
+
const from = space.canvasToPlan(anchor, camera, _fromPlan);
|
|
191
|
+
const to = space.canvasToPlan({ x: anchor.x + step[0], y: anchor.y + step[1] }, camera, _toPlan);
|
|
192
|
+
if (!from || !to)
|
|
193
|
+
return false;
|
|
194
|
+
space.planToWorld(from, _fromWorld);
|
|
195
|
+
space.planToWorld(to, _toWorld);
|
|
196
|
+
const target = controls.getTarget(_target, false).add(_fromWorld).sub(_toWorld);
|
|
197
|
+
void controls.moveTo(target.x, target.y, target.z, false);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The controls' pan bits. A two-finger pinch that pans as it dollies carries one too, and its pan
|
|
202
|
+
* is sampled like any other: the centroid measures the pan alone.
|
|
203
|
+
*/
|
|
204
|
+
const PAN = CameraControlsAction.TRUCK |
|
|
205
|
+
CameraControlsAction.SCREEN_PAN |
|
|
206
|
+
CameraControlsAction.TOUCH_TRUCK |
|
|
207
|
+
CameraControlsAction.TOUCH_SCREEN_PAN;
|
|
208
|
+
const _fromPlan = new Point();
|
|
209
|
+
const _toPlan = new Point();
|
|
210
|
+
const _fromWorld = new Vector3();
|
|
211
|
+
const _toWorld = new Vector3();
|
|
212
|
+
const _target = new Vector3();
|
|
@@ -98,21 +98,30 @@ class PitchGesture {
|
|
|
98
98
|
if (this.prevTwoFingerAction === undefined) {
|
|
99
99
|
this.prevTwoFingerAction = controls.touches.two;
|
|
100
100
|
controls.touches.two = CameraControlsAction.NONE;
|
|
101
|
+
// Bracketed from the moment the drag is judged a tilt, which is also the moment it first
|
|
102
|
+
// moves the camera.
|
|
103
|
+
controls.dispatchEvent({ type: 'controlstart' });
|
|
101
104
|
}
|
|
102
105
|
lastPoints[0].add(this.p0);
|
|
103
106
|
lastPoints[1].add(this.p1);
|
|
104
107
|
const yDeltaAverage = (this.p0.y + this.p1.y) / 2;
|
|
105
108
|
const deltaAngle = degToRad(yDeltaAverage * DEGREES_PER_PIXEL);
|
|
106
109
|
void controls.rotatePolarTo(controls.polarAngle + deltaAngle, false);
|
|
110
|
+
// The tilt moves the camera through a public setter, which the controls count as a command
|
|
111
|
+
// rather than the user's hand; reporting it is what tells an idle timer the user is here.
|
|
112
|
+
controls.dispatchEvent({ type: 'control' });
|
|
107
113
|
}
|
|
108
114
|
/**
|
|
109
|
-
* Puts back the two-finger action this suppressed, if it suppressed one,
|
|
115
|
+
* Puts back the two-finger action this suppressed, if it suppressed one, closes the
|
|
116
|
+
* `controlstart` the tilt opened, and forgets the gesture.
|
|
110
117
|
*
|
|
111
118
|
* Nothing is restored when the captured action was already `NONE`: there was nothing to suppress,
|
|
112
119
|
* and writing it back would claim a field this handler does not own.
|
|
113
|
-
* @param controls - The controls to restore, if they are still there.
|
|
120
|
+
* @param controls - The controls to restore and report to, if they are still there.
|
|
114
121
|
*/
|
|
115
122
|
end(controls) {
|
|
123
|
+
if (this.prevTwoFingerAction !== undefined)
|
|
124
|
+
controls?.dispatchEvent({ type: 'controlend' });
|
|
116
125
|
if (controls && this.prevTwoFingerAction)
|
|
117
126
|
controls.touches.two = this.prevTwoFingerAction;
|
|
118
127
|
this.prevTwoFingerAction = undefined;
|
|
@@ -34,6 +34,8 @@ export function Roll(props) {
|
|
|
34
34
|
controls.mouseButtons.right |= CameraControlsAction.ROTATE_AZIMUTH;
|
|
35
35
|
return () => {
|
|
36
36
|
controls.mouseButtons.right &= ~CameraControlsAction.ROTATE_AZIMUTH;
|
|
37
|
+
// Unmounting mid-twist would otherwise leave a `controlstart` without its `controlend`.
|
|
38
|
+
gesture.end(controls);
|
|
37
39
|
};
|
|
38
40
|
});
|
|
39
41
|
// Each of these is cached by the React Compiler against what it captures, so `useGesture`
|
|
@@ -43,7 +45,7 @@ export function Roll(props) {
|
|
|
43
45
|
if (controls)
|
|
44
46
|
gesture.move(event, threshold, { controls, camera: get().camera, space });
|
|
45
47
|
};
|
|
46
|
-
const onRotateEnd = () => gesture.end();
|
|
48
|
+
const onRotateEnd = () => gesture.end(controls);
|
|
47
49
|
useGesture('rotatestart', onRotateStart);
|
|
48
50
|
useGesture('rotate', onRotate);
|
|
49
51
|
useGesture('rotateend', onRotateEnd);
|
|
@@ -102,6 +104,9 @@ class RollGesture {
|
|
|
102
104
|
return;
|
|
103
105
|
this.isRolling = true;
|
|
104
106
|
this.prevAngle = event.rotation;
|
|
107
|
+
// Bracketed from the moment the plan starts turning, not from the touch: a twist still inside
|
|
108
|
+
// its threshold has not moved the camera, and may yet turn out to be a pinch.
|
|
109
|
+
controls.dispatchEvent({ type: 'controlstart' });
|
|
105
110
|
}
|
|
106
111
|
// Negated: the recognizer measures the twist clockwise on screen, and the azimuth it drives
|
|
107
112
|
// grows the other way.
|
|
@@ -118,9 +123,18 @@ class RollGesture {
|
|
|
118
123
|
this.cameraForward.applyMatrix4(this.rotation);
|
|
119
124
|
this.targetWorld.copy(this.cameraPosition).add(this.cameraForward);
|
|
120
125
|
void controls.setLookAt(this.cameraPosition.x, this.cameraPosition.y, this.cameraPosition.z, this.targetWorld.x, this.targetWorld.y, this.targetWorld.z, false);
|
|
126
|
+
// The twist moves the camera through the public setters, which the controls count as a command
|
|
127
|
+
// rather than the user's hand; reporting it is what tells an idle timer the user is here.
|
|
128
|
+
controls.dispatchEvent({ type: 'control' });
|
|
121
129
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Forgets the gesture, so the next twist measures its threshold afresh, and closes the
|
|
132
|
+
* `controlstart` the twist opened, if it got far enough to open one.
|
|
133
|
+
* @param controls - The controls to report to, if they are still there.
|
|
134
|
+
*/
|
|
135
|
+
end(controls) {
|
|
136
|
+
if (this.isRolling)
|
|
137
|
+
controls?.dispatchEvent({ type: 'controlend' });
|
|
124
138
|
this.isRolling = false;
|
|
125
139
|
this.startVector = undefined;
|
|
126
140
|
this.minDiameter = 0;
|
|
@@ -1,15 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** Options for the zoom handler, which are also its props. */
|
|
3
|
+
export interface ZoomConfig {
|
|
4
|
+
/**
|
|
5
|
+
* What a double-click or a double-tap multiplies the zoom factor by, zooming toward the point
|
|
6
|
+
* under the pointer and keeping it there. Off when left out.
|
|
7
|
+
*/
|
|
8
|
+
dblclick?: number;
|
|
9
|
+
}
|
|
1
10
|
/**
|
|
2
|
-
* Zooming: the wheel,
|
|
11
|
+
* Zooming: the wheel, a two-finger pinch, and — when asked for — a double-click or double-tap.
|
|
3
12
|
*
|
|
4
|
-
* Nothing here beyond the actions — camera-controls recognizes both gestures
|
|
5
|
-
* has already told it to dolly toward the cursor rather than the centre. The
|
|
6
|
-
* as well as pinches, which is what makes a pinch feel like it holds the plan
|
|
7
|
-
* the twist half of a two-finger gesture is `Roll`'s, and comes off the gesture
|
|
8
|
-
* than from here, so the two never contend for `touches.two`.
|
|
13
|
+
* Nothing here beyond the actions for the first two — camera-controls recognizes both gestures
|
|
14
|
+
* itself, and `Controls` has already told it to dolly toward the cursor rather than the centre. The
|
|
15
|
+
* two-finger action pans as well as pinches, which is what makes a pinch feel like it holds the plan
|
|
16
|
+
* under the fingers; the twist half of a two-finger gesture is `Roll`'s, and comes off the gesture
|
|
17
|
+
* recognizer rather than from here, so the two never contend for `touches.two`.
|
|
18
|
+
*
|
|
19
|
+
* The double-click is not camera-controls' either, so it comes off the gesture recognizer, which
|
|
20
|
+
* reads a mouse and a finger alike: see {@link DoubleTapZoom}.
|
|
9
21
|
*
|
|
10
22
|
* Mounting is enabling. The actions are set when this mounts and returned to `NONE` when it
|
|
11
23
|
* unmounts, which is the whole of what the imperative renderer's `enable()` / `disable()` did.
|
|
12
|
-
* @
|
|
24
|
+
* @param props - Whether a double-click zooms, and by how much.
|
|
25
|
+
* @returns The double-click zoom, when there is one.
|
|
13
26
|
*/
|
|
14
|
-
export declare function Zoom():
|
|
27
|
+
export declare function Zoom(props: ZoomConfig): ReactNode;
|
|
15
28
|
//# sourceMappingURL=Zoom.d.ts.map
|
|
@@ -1,19 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
2
|
import { CameraControlsAction } from '../CameraControls';
|
|
2
3
|
import { useControlsEffect } from '../hooks';
|
|
4
|
+
import { DoubleTapZoom } from './DoubleTapZoom';
|
|
3
5
|
/**
|
|
4
|
-
* Zooming: the wheel,
|
|
6
|
+
* Zooming: the wheel, a two-finger pinch, and — when asked for — a double-click or double-tap.
|
|
5
7
|
*
|
|
6
|
-
* Nothing here beyond the actions — camera-controls recognizes both gestures
|
|
7
|
-
* has already told it to dolly toward the cursor rather than the centre. The
|
|
8
|
-
* as well as pinches, which is what makes a pinch feel like it holds the plan
|
|
9
|
-
* the twist half of a two-finger gesture is `Roll`'s, and comes off the gesture
|
|
10
|
-
* than from here, so the two never contend for `touches.two`.
|
|
8
|
+
* Nothing here beyond the actions for the first two — camera-controls recognizes both gestures
|
|
9
|
+
* itself, and `Controls` has already told it to dolly toward the cursor rather than the centre. The
|
|
10
|
+
* two-finger action pans as well as pinches, which is what makes a pinch feel like it holds the plan
|
|
11
|
+
* under the fingers; the twist half of a two-finger gesture is `Roll`'s, and comes off the gesture
|
|
12
|
+
* recognizer rather than from here, so the two never contend for `touches.two`.
|
|
13
|
+
*
|
|
14
|
+
* The double-click is not camera-controls' either, so it comes off the gesture recognizer, which
|
|
15
|
+
* reads a mouse and a finger alike: see {@link DoubleTapZoom}.
|
|
11
16
|
*
|
|
12
17
|
* Mounting is enabling. The actions are set when this mounts and returned to `NONE` when it
|
|
13
18
|
* unmounts, which is the whole of what the imperative renderer's `enable()` / `disable()` did.
|
|
14
|
-
* @
|
|
19
|
+
* @param props - Whether a double-click zooms, and by how much.
|
|
20
|
+
* @returns The double-click zoom, when there is one.
|
|
15
21
|
*/
|
|
16
|
-
export function Zoom() {
|
|
22
|
+
export function Zoom(props) {
|
|
17
23
|
useControlsEffect((controls) => {
|
|
18
24
|
controls.mouseButtons.wheel = CameraControlsAction.DOLLY;
|
|
19
25
|
controls.touches.two = CameraControlsAction.TOUCH_DOLLY_SCREEN_PAN;
|
|
@@ -22,5 +28,7 @@ export function Zoom() {
|
|
|
22
28
|
controls.touches.two = CameraControlsAction.NONE;
|
|
23
29
|
};
|
|
24
30
|
});
|
|
25
|
-
|
|
31
|
+
if (props.dblclick === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
return _jsx(DoubleTapZoom, { factor: props.dblclick });
|
|
26
34
|
}
|
package/dist/controls/index.d.ts
CHANGED
|
@@ -4,5 +4,6 @@ export { Controls, type ControlsConfig, type ControlsProps } from './Controls';
|
|
|
4
4
|
export type { InertiaConfig } from './handlers/Inertia';
|
|
5
5
|
export type { PanConfig } from './handlers/Pan';
|
|
6
6
|
export type { RollConfig } from './handlers/Roll';
|
|
7
|
+
export type { ZoomConfig } from './handlers/Zoom';
|
|
7
8
|
export { type CameraState, useCameraAPI, useCameraControls, useCameraGesture, useCameraMoveEnd, useCameraState, useControlsEffect, } from './hooks';
|
|
8
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type CameraAPI, type CameraAxes, type CameraState, type ControlsConfig, type InertiaConfig, type PanConfig, type RollConfig, type TransitionOptions, useCameraAPI, useCameraGesture, useCameraMoveEnd, useCameraState, type ZoomToOptions, } from './controls';
|
|
1
|
+
export { type CameraAPI, type CameraAxes, type CameraState, type ControlsConfig, type InertiaConfig, type PanConfig, type RollConfig, type TransitionOptions, useCameraAPI, useCameraGesture, useCameraMoveEnd, useCameraState, type ZoomConfig, type ZoomToOptions, } from './controls';
|
|
2
2
|
export { prepareDebug } from './debug/debug-chunk';
|
|
3
3
|
export { useDebugPanel } from './debug/debug-context';
|
|
4
4
|
export { type LightingProps, type ShadowProps } from './objects/lighting';
|
package/dist/types/events.d.ts
CHANGED
|
@@ -27,7 +27,11 @@ export interface PointerEventData<T extends RenderableDef = RenderableDef> {
|
|
|
27
27
|
* A fresh instance per event, so keeping a reference to it is safe.
|
|
28
28
|
*/
|
|
29
29
|
point: PlanPoint;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Ends the event's delivery: neither the ancestors of the layer whose handler is running nor any
|
|
32
|
+
* def farther along the ray receives it. A handler that wants only the nearest of several
|
|
33
|
+
* overlapping defs calls this.
|
|
34
|
+
*/
|
|
31
35
|
stopPropagation(): void;
|
|
32
36
|
/** The DOM event this was dispatched from. */
|
|
33
37
|
nativeEvent: MouseEvent;
|
|
@@ -59,10 +63,15 @@ export interface PointerMissedEventData {
|
|
|
59
63
|
* with any is raycast together with its whole subtree; a layer that is off screen — hidden, or under
|
|
60
64
|
* something hidden — is not picked at all.
|
|
61
65
|
*
|
|
62
|
-
* **Events travel upward.** The nearest
|
|
63
|
-
* the same handler,
|
|
64
|
-
* def that was hit. Overlapping defs
|
|
65
|
-
*
|
|
66
|
+
* **Events travel upward and away.** The nearest def's layer handler runs first, then each ancestor
|
|
67
|
+
* that declares the same handler, then the next def along the ray, until one calls
|
|
68
|
+
* {@link PointerEventData.stopPropagation}; `event.def` stays the def that was hit. Overlapping defs
|
|
69
|
+
* deliver one event apiece, even within one layer. Defs at the same distance, such as coplanar flat
|
|
70
|
+
* defs, arrive in the order their slots were claimed (mount order), not paint order;
|
|
71
|
+
* `Pointer/Overlap` pins that.
|
|
72
|
+
*
|
|
73
|
+
* **Only interactive layers are hit.** A layer with no handlers is not raycast, so it never shields
|
|
74
|
+
* a def behind it: a booth behind a wall still receives the event.
|
|
66
75
|
*
|
|
67
76
|
* **Handlers return nothing.** One that mutates defs calls `update()` afterward, like any other code.
|
|
68
77
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expofp/renderer",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.41.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/expofp/efp-app.git",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@react-three/fiber": "^9.6.1",
|
|
35
|
-
"camera-controls": "github:expofp/camera-controls#v3.1.3-expofp.
|
|
35
|
+
"camera-controls": "github:expofp/camera-controls#v3.1.3-expofp.7",
|
|
36
36
|
"colord": "^2.9.3",
|
|
37
37
|
"lil-gui": "^0.21.0",
|
|
38
38
|
"maxrects-packer": "^2.7.3",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"troika-three-text": "^0.52.5",
|
|
43
43
|
"tslib": "^2.3.0",
|
|
44
44
|
"webgl-memory": "^1.1.3",
|
|
45
|
-
"@expofp/geometry": "3.
|
|
45
|
+
"@expofp/geometry": "3.41.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@react-three/test-renderer": "^9.1.0",
|