@weasel-js/labkit 1.6.0 → 1.6.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/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
- package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
- package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-BoELAMsM.d.ts} +2 -2
- package/dist/_dts/{index-DINmCuLA.d.ts → PropertyField.d-D0ndfIIt.d.ts} +34 -83
- package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Cr8-zoup.d.ts} +4 -5
- package/dist/_dts/{usePanZoom-Bq73pwFe.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
- package/dist/_dts/{index-B0--8PEM.d.ts → index-CKyX0ME_.d.ts} +12 -13
- package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-B7yBTTF9.d.ts} +699 -649
- package/dist/_dts/{types-B7y8hX1D.d.ts → types-DTuViBvc.d.ts} +20 -1
- package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
- package/dist/canvas/index.d.ts +39 -7
- package/dist/canvas/index.js +5 -3
- package/dist/chrome/index.d.ts +4 -5
- package/dist/chrome/index.js +8 -6
- package/dist/chunk-24W5VEJW.js +18 -0
- package/dist/chunk-24W5VEJW.js.map +1 -0
- package/dist/chunk-3RY6WL5T.js +38 -0
- package/dist/chunk-3RY6WL5T.js.map +1 -0
- package/dist/{chunk-H5VIV7MQ.js → chunk-4TT3PNPR.js} +60 -51
- package/dist/chunk-4TT3PNPR.js.map +1 -0
- package/dist/{chunk-ZORVNOKX.js → chunk-6FLXL3I7.js} +9 -145
- package/dist/chunk-6FLXL3I7.js.map +1 -0
- package/dist/{chunk-HFRE4QQV.js → chunk-ASOK76ZH.js} +53 -22
- package/dist/chunk-ASOK76ZH.js.map +1 -0
- package/dist/chunk-AWQG7FCC.js +19 -0
- package/dist/chunk-AWQG7FCC.js.map +1 -0
- package/dist/chunk-CGHF6RZV.js +300 -0
- package/dist/chunk-CGHF6RZV.js.map +1 -0
- package/dist/chunk-DWMSB4CA.js +228 -0
- package/dist/chunk-DWMSB4CA.js.map +1 -0
- package/dist/{chunk-USO6TERP.js → chunk-GHGO52SO.js} +11 -9
- package/dist/chunk-GHGO52SO.js.map +1 -0
- package/dist/{chunk-MOLIUNVH.js → chunk-HKV7LRHY.js} +4 -4
- package/dist/{chunk-MOLIUNVH.js.map → chunk-HKV7LRHY.js.map} +1 -1
- package/dist/{chunk-VQ47KWD5.js → chunk-IRTGUQ5O.js} +5 -4
- package/dist/chunk-IRTGUQ5O.js.map +1 -0
- package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
- package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
- package/dist/chunk-KHK5FGER.js +145 -0
- package/dist/chunk-KHK5FGER.js.map +1 -0
- package/dist/chunk-NEXR37YJ.js +144 -0
- package/dist/chunk-NEXR37YJ.js.map +1 -0
- package/dist/{chunk-BVL7Y574.js → chunk-O6FFFOHS.js} +14 -6
- package/dist/chunk-O6FFFOHS.js.map +1 -0
- package/dist/chunk-QGMOA2YE.js +731 -0
- package/dist/chunk-QGMOA2YE.js.map +1 -0
- package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
- package/dist/chunk-S4NCI32V.js.map +1 -0
- package/dist/{chunk-3FI7Y4R4.js → chunk-SLAQPUI4.js} +4 -4
- package/dist/{chunk-3FI7Y4R4.js.map → chunk-SLAQPUI4.js.map} +1 -1
- package/dist/{chunk-AWH3SRPW.js → chunk-TJ77A4A3.js} +3 -31
- package/dist/chunk-TJ77A4A3.js.map +1 -0
- package/dist/{chunk-WUJXJC5O.js → chunk-TYN6MLXX.js} +691 -1395
- package/dist/chunk-TYN6MLXX.js.map +1 -0
- package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
- package/dist/chunk-WPLAANH4.js.map +1 -0
- package/dist/config/index.d.ts +36 -35
- package/dist/config/index.js +4 -5
- package/dist/controls/index.d.ts +91 -7
- package/dist/controls/index.js +5 -4
- package/dist/dragdrop/index.d.ts +8 -6
- package/dist/dragdrop/index.js +3 -2
- package/dist/index.d.ts +50 -77
- package/dist/index.js +138 -254
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -4
- package/dist/layers/index.js +2 -1
- package/dist/loupe/index.d.ts +19 -16
- package/dist/loupe/index.js +5 -3
- package/dist/overview/index.d.ts +56 -0
- package/dist/overview/index.js +288 -0
- package/dist/overview/index.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +38 -38
- package/dist/passthrough/weasel-ui.js +2 -1
- package/dist/primitives/index.d.ts +19 -43
- package/dist/primitives/index.js +5 -3
- package/dist/state/index.d.ts +7 -7
- package/dist/state/index.js +4 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +63 -1
- package/dist/undo/index.d.ts +3 -3
- package/package.json +13 -9
- package/src/annotations/index.ts +1 -0
- package/src/annotations/store.test.ts +26 -0
- package/src/annotations/store.ts +25 -6
- package/src/annotations/toolMap.test.ts +37 -2
- package/src/annotations/toolMap.ts +13 -0
- package/src/annotations/types.ts +27 -0
- package/src/canvas/AGENTS.md +8 -10
- package/src/canvas/CameraInput.test.tsx +136 -0
- package/src/canvas/CameraInput.tsx +311 -0
- package/src/canvas/CanvasStack.tsx +42 -58
- package/src/canvas/LinkedCursor.less +16 -0
- package/src/canvas/LinkedCursor.tsx +54 -0
- package/src/canvas/Stage.tsx +42 -37
- package/src/canvas/cameraView.test.ts +60 -0
- package/src/canvas/cameraView.ts +61 -0
- package/src/canvas/index.ts +11 -2
- package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
- package/src/config/builder.ts +3 -3
- package/src/config/entry.test.ts +3 -1
- package/src/config/fromConfigField.ts +1 -0
- package/src/config/index.ts +4 -1
- package/src/config/resolve.ts +11 -1
- package/src/config/sectionTree.test.ts +12 -6
- package/src/config/sectionTree.ts +15 -5
- package/src/config/types.ts +21 -0
- package/src/controls/ControlPanel.test.tsx +12 -2
- package/src/controls/ControlPanel.tsx +28 -9
- package/src/controls/inDialog.test.tsx +18 -0
- package/src/controls/inDialog.tsx +2 -10
- package/src/index.test.ts +1 -1
- package/src/index.ts +8 -4
- package/src/instrument/SineWave.smoke.test.tsx +2 -0
- package/src/instrument/types.ts +10 -1
- package/src/lab/Lab.nebula.test.tsx +11 -2
- package/src/lab/Lab.test.tsx +3 -1
- package/src/lab/Lab.tsx +4 -4
- package/src/loupe/AGENTS.md +7 -6
- package/src/loupe/LoupeGestures.tsx +14 -9
- package/src/loupe/TrialLoupe.tsx +5 -5
- package/src/loupe/index.ts +1 -0
- package/src/loupe/loupeActions.ts +6 -2
- package/src/overview/AGENTS.md +38 -0
- package/src/overview/OverviewMarks.test.tsx +100 -0
- package/src/overview/OverviewMarks.tsx +93 -0
- package/src/overview/TrialOverview.less +53 -0
- package/src/overview/TrialOverview.stories.tsx +50 -0
- package/src/overview/TrialOverview.test.tsx +116 -0
- package/src/overview/TrialOverview.tsx +374 -0
- package/src/overview/index.ts +2 -0
- package/src/passthrough/weasel-ui.ts +7 -0
- package/src/specimen/Specimen.tsx +19 -2
- package/src/state/labRecords.test.ts +4 -1
- package/src/state/store.test.ts +4 -4
- package/src/state/useTrialState.test.tsx +1 -1
- package/src/styles.less +2 -0
- package/src/theme/Interstellar.stories.tsx +1 -5
- package/src/tools/labTool.ts +6 -2
- package/src/trial/Trial.annotations.test.tsx +16 -0
- package/src/trial/Trial.loupe.test.tsx +73 -1
- package/src/trial/Trial.stories.tsx +2 -0
- package/src/trial/Trial.tsx +17 -1
- package/src/trial/trialOps.test.ts +4 -4
- package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
- package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
- package/dist/chunk-2AYGEN57.js +0 -32
- package/dist/chunk-2AYGEN57.js.map +0 -1
- package/dist/chunk-AWH3SRPW.js.map +0 -1
- package/dist/chunk-BVL7Y574.js.map +0 -1
- package/dist/chunk-ERFMC6WU.js.map +0 -1
- package/dist/chunk-H5VIV7MQ.js.map +0 -1
- package/dist/chunk-HFRE4QQV.js.map +0 -1
- package/dist/chunk-RS3HRQWU.js.map +0 -1
- package/dist/chunk-TT5C5S6R.js +0 -368
- package/dist/chunk-TT5C5S6R.js.map +0 -1
- package/dist/chunk-USO6TERP.js.map +0 -1
- package/dist/chunk-VQ47KWD5.js.map +0 -1
- package/dist/chunk-WUJXJC5O.js.map +0 -1
- package/dist/chunk-ZORVNOKX.js.map +0 -1
- package/src/canvas/usePanZoom.test.ts +0 -244
- package/src/canvas/usePanZoom.ts +0 -147
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A labkit camera driven by weasel's dispatcher: drag pans, the wheel zooms
|
|
3
|
+
* about the pointer, a tap reports the world point, and the pointer is
|
|
4
|
+
* published into the store in scope. What `usePanZoom` used to hand-roll.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
type Action,
|
|
8
|
+
ActionsProvider,
|
|
9
|
+
type ActionsRegistry,
|
|
10
|
+
ActiveToolContextProvider,
|
|
11
|
+
DepRegistryProvider,
|
|
12
|
+
makeViewportZoomAction,
|
|
13
|
+
PointerContextProvider,
|
|
14
|
+
type PointerWorldPos,
|
|
15
|
+
type Tool,
|
|
16
|
+
useActionsRegistry,
|
|
17
|
+
useDepSource,
|
|
18
|
+
useGestureDispatcher,
|
|
19
|
+
usePointerContext,
|
|
20
|
+
type View,
|
|
21
|
+
type ViewApi,
|
|
22
|
+
viewportDragPanAction,
|
|
23
|
+
} from '@weasel-js/core';
|
|
24
|
+
import {
|
|
25
|
+
createContext,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
type RefObject,
|
|
28
|
+
useContext,
|
|
29
|
+
useEffect,
|
|
30
|
+
useMemo,
|
|
31
|
+
useRef,
|
|
32
|
+
} from 'react';
|
|
33
|
+
import type { Point, ViewTransform } from '../instrument/types';
|
|
34
|
+
import { normalize2DView } from '../state/view';
|
|
35
|
+
import { CameraWheelContext } from './CameraWheelContext';
|
|
36
|
+
import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
|
|
37
|
+
import type { ViewportSize, WorldFrame } from './worldSpec';
|
|
38
|
+
|
|
39
|
+
/** The view id the stage publishes its pointer under. */
|
|
40
|
+
export const STAGE_VIEW_ID = 'stage';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A camera, as the things that sit beside it read it: an overview, a loupe, a
|
|
44
|
+
* linked cursor. Points are *frame-local* — the world with `y` multiplied by
|
|
45
|
+
* `frame.yDir` — which is the space `view` works in.
|
|
46
|
+
*/
|
|
47
|
+
export interface CameraContextValue {
|
|
48
|
+
/** The camera as a weasel `ViewApi`, clamped to its zoom range. */
|
|
49
|
+
view: ViewApi;
|
|
50
|
+
frame: WorldFrame;
|
|
51
|
+
/** The element the camera's dispatcher listens on. */
|
|
52
|
+
element: () => HTMLElement | null;
|
|
53
|
+
/** The content's own rect, frame-local, when it has one — a `<Stage>`'s. */
|
|
54
|
+
content?: { x: number; y: number; width: number; height: number };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The camera around the caller, or `null` outside one. */
|
|
58
|
+
export const CameraContext = createContext<CameraContextValue | null>(null);
|
|
59
|
+
|
|
60
|
+
/** Set by a host that already provides one input scope for everything inside
|
|
61
|
+
* it — a trial — so the camera, the loupe and an overview share it. */
|
|
62
|
+
export const CameraScopeContext = createContext(false);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The actions and deps a camera routes through: the ones a trial provides, or
|
|
66
|
+
* an isolated set of its own. Isolated, because an actions registry holds one
|
|
67
|
+
* dispatcher, and a camera must not take input from a canvas beside it. The
|
|
68
|
+
* pointer store is the exception — one already in scope is kept, since sharing
|
|
69
|
+
* it is the point.
|
|
70
|
+
*/
|
|
71
|
+
export function CameraScope({ children }: { children: ReactNode }) {
|
|
72
|
+
const inScope = useContext(CameraScopeContext);
|
|
73
|
+
const pointer = usePointerContext();
|
|
74
|
+
if (inScope) return <>{children}</>;
|
|
75
|
+
const scoped = <CameraScopeContext.Provider value={true}>{children}</CameraScopeContext.Provider>;
|
|
76
|
+
return (
|
|
77
|
+
<DepRegistryProvider>
|
|
78
|
+
<ActionsProvider>
|
|
79
|
+
<ActiveToolContextProvider>
|
|
80
|
+
{pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>}
|
|
81
|
+
</ActiveToolContextProvider>
|
|
82
|
+
</ActionsProvider>
|
|
83
|
+
</DepRegistryProvider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Options for {@link useCameraView}. */
|
|
88
|
+
export interface CameraViewOptions {
|
|
89
|
+
view: ViewTransform;
|
|
90
|
+
onViewChange: (v: ViewTransform) => void;
|
|
91
|
+
frame: WorldFrame;
|
|
92
|
+
hostRef: RefObject<HTMLElement | null>;
|
|
93
|
+
minZoom?: number;
|
|
94
|
+
maxZoom?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isPositiveFinite(n: number): boolean {
|
|
98
|
+
return Number.isFinite(n) && n > 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A labkit camera as a stable `ViewApi`. Its zoom range is widened to keep the
|
|
103
|
+
* opening zoom reachable — captured once, so a zoom-out cannot shrink the
|
|
104
|
+
* range and strand the opening view behind it.
|
|
105
|
+
*/
|
|
106
|
+
export function useCameraView({
|
|
107
|
+
view,
|
|
108
|
+
onViewChange,
|
|
109
|
+
frame,
|
|
110
|
+
hostRef,
|
|
111
|
+
minZoom = 0.1,
|
|
112
|
+
maxZoom = 32,
|
|
113
|
+
}: CameraViewOptions): ViewApi {
|
|
114
|
+
const live = useRef({ view: normalize2DView(view), onViewChange, frame });
|
|
115
|
+
live.current = { view: normalize2DView(view), onViewChange, frame };
|
|
116
|
+
const initialZoom = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
117
|
+
const bounds = useRef({ min: minZoom, max: maxZoom });
|
|
118
|
+
const opening = initialZoom.current;
|
|
119
|
+
bounds.current = {
|
|
120
|
+
min: opening == null ? minZoom : Math.min(minZoom, opening),
|
|
121
|
+
max: opening == null ? maxZoom : Math.max(maxZoom, opening),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return useMemo<ViewApi>(() => {
|
|
125
|
+
const get = (): View => toCameraView(live.current.view, live.current.frame);
|
|
126
|
+
return {
|
|
127
|
+
get,
|
|
128
|
+
set: (next: View) => {
|
|
129
|
+
const { min, max } = bounds.current;
|
|
130
|
+
const clamped = clampZoomAbout(get(), next, min, max);
|
|
131
|
+
const vt = fromCameraView(clamped, live.current.frame);
|
|
132
|
+
// Written ahead of the render, so a second event inside one commit
|
|
133
|
+
// steps from this one.
|
|
134
|
+
live.current.view = vt;
|
|
135
|
+
live.current.onViewChange(vt);
|
|
136
|
+
},
|
|
137
|
+
hostSize: () => {
|
|
138
|
+
const r = hostRef.current?.getBoundingClientRect();
|
|
139
|
+
return r ? { width: r.width, height: r.height } : null;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}, [hostRef]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const TAP_ID = 'camera.tap';
|
|
146
|
+
const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
|
|
147
|
+
const CHANNELS = { contextMenu: false, ingest: false } as const;
|
|
148
|
+
|
|
149
|
+
/** Props for `<CameraInput>`. */
|
|
150
|
+
export interface CameraInputProps {
|
|
151
|
+
hostRef: RefObject<HTMLElement | null>;
|
|
152
|
+
camera: ViewApi;
|
|
153
|
+
frame: WorldFrame;
|
|
154
|
+
/** A primary-button press released without crossing the drag threshold, at
|
|
155
|
+
* the world point it landed on. */
|
|
156
|
+
onTap?: (world: Point) => void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Mounts the dispatcher on `hostRef` and routes the camera's gestures through
|
|
161
|
+
* it. Render it inside a `<CameraScope>`; with no actions registry in scope it
|
|
162
|
+
* renders nothing.
|
|
163
|
+
*/
|
|
164
|
+
export function CameraInput(props: CameraInputProps) {
|
|
165
|
+
const registry = useActionsRegistry();
|
|
166
|
+
if (!registry) return null;
|
|
167
|
+
return <CameraDispatch {...props} registry={registry} />;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function CameraDispatch({
|
|
171
|
+
hostRef,
|
|
172
|
+
camera,
|
|
173
|
+
frame,
|
|
174
|
+
onTap,
|
|
175
|
+
registry,
|
|
176
|
+
}: CameraInputProps & { registry: ActionsRegistry }) {
|
|
177
|
+
const frameRef = useRef(frame);
|
|
178
|
+
frameRef.current = frame;
|
|
179
|
+
const onTapRef = useRef(onTap);
|
|
180
|
+
onTapRef.current = onTap;
|
|
181
|
+
|
|
182
|
+
useDepSource('view', () => camera);
|
|
183
|
+
useDepSource('rootView', () => camera);
|
|
184
|
+
|
|
185
|
+
const actions = useMemo<Action[]>(() => {
|
|
186
|
+
// Unclamped here: `camera.set` clamps, to a range widened around the
|
|
187
|
+
// opening zoom, and anchors the clamp where the wheel was.
|
|
188
|
+
const zoom = makeViewportZoomAction({
|
|
189
|
+
wheel: 'plain',
|
|
190
|
+
min: 1e-9,
|
|
191
|
+
max: Number.POSITIVE_INFINITY,
|
|
192
|
+
});
|
|
193
|
+
// The wheel and the trackpad's two pinch forms only: the zoom keys listen
|
|
194
|
+
// on the window, and every trial on a page would take them at once.
|
|
195
|
+
const wheelOnly = (zoom.defaultBinding as { spec: { kind: string } }[]).filter(
|
|
196
|
+
(b) => b.spec.kind === 'wheel' || b.spec.kind === 'pinch',
|
|
197
|
+
);
|
|
198
|
+
const tap: Action = {
|
|
199
|
+
id: TAP_ID,
|
|
200
|
+
label: 'Tap the canvas',
|
|
201
|
+
defaultBinding: { kind: 'click' },
|
|
202
|
+
invoker: {
|
|
203
|
+
timing: 'immediate',
|
|
204
|
+
run: (_deps, params) => {
|
|
205
|
+
const p = params as { worldX?: number; worldY?: number } | undefined;
|
|
206
|
+
if (p?.worldX === undefined || p.worldY === undefined) return;
|
|
207
|
+
onTapRef.current?.(frameLocalToWorld({ x: p.worldX, y: p.worldY }, frameRef.current));
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
return [
|
|
212
|
+
viewportDragPanAction,
|
|
213
|
+
{ ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
|
|
214
|
+
tap,
|
|
215
|
+
];
|
|
216
|
+
}, []);
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const offs = actions.map((a) => registry.register(a));
|
|
220
|
+
return () => {
|
|
221
|
+
for (const off of offs) off();
|
|
222
|
+
};
|
|
223
|
+
}, [registry, actions]);
|
|
224
|
+
|
|
225
|
+
const clientToWorld = useMemo(
|
|
226
|
+
() => (cx: number, cy: number) => {
|
|
227
|
+
const r = hostRef.current?.getBoundingClientRect();
|
|
228
|
+
const v = camera.get();
|
|
229
|
+
return {
|
|
230
|
+
x: (cx - (r?.left ?? 0)) / v.scale.x + v.x,
|
|
231
|
+
y: (cy - (r?.top ?? 0)) / v.scale.y + v.y,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
[hostRef, camera],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
useGestureDispatcher({
|
|
238
|
+
canvasRef: hostRef,
|
|
239
|
+
actions: registry,
|
|
240
|
+
toolsById: NO_TOOLS,
|
|
241
|
+
clientToWorld,
|
|
242
|
+
channels: CHANNELS,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
usePublishPointer(hostRef, clientToWorld, frame, STAGE_VIEW_ID);
|
|
246
|
+
|
|
247
|
+
// Input that lands outside the camera's element — an annotation target's
|
|
248
|
+
// box, portalled into the surface — reaches the dispatcher through here.
|
|
249
|
+
const wheelSlot = useContext(CameraWheelContext);
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (!wheelSlot) return;
|
|
252
|
+
const forward = (e: WheelEvent): void => {
|
|
253
|
+
hostRef.current?.dispatchEvent(new WheelEvent('wheel', e));
|
|
254
|
+
};
|
|
255
|
+
wheelSlot.current = forward;
|
|
256
|
+
return () => {
|
|
257
|
+
if (wheelSlot.current === forward) wheelSlot.current = null;
|
|
258
|
+
};
|
|
259
|
+
}, [wheelSlot, hostRef]);
|
|
260
|
+
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Publish the pointer over `hostRef` into the store in scope, in the
|
|
266
|
+
* instrument's world, as `viewId`; clear it when the pointer leaves with no
|
|
267
|
+
* button held.
|
|
268
|
+
*/
|
|
269
|
+
export function usePublishPointer(
|
|
270
|
+
hostRef: RefObject<HTMLElement | null>,
|
|
271
|
+
clientToLocal: (cx: number, cy: number) => Point,
|
|
272
|
+
frame: WorldFrame,
|
|
273
|
+
viewId: string,
|
|
274
|
+
): void {
|
|
275
|
+
const store = usePointerContext();
|
|
276
|
+
const frameRef = useRef(frame);
|
|
277
|
+
frameRef.current = frame;
|
|
278
|
+
const toLocal = useRef(clientToLocal);
|
|
279
|
+
toLocal.current = clientToLocal;
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const el = hostRef.current;
|
|
282
|
+
if (!el || !store) return;
|
|
283
|
+
const onMove = (e: PointerEvent): void => {
|
|
284
|
+
const w = frameLocalToWorld(toLocal.current(e.clientX, e.clientY), frameRef.current);
|
|
285
|
+
store.set({ worldX: w.x, worldY: w.y, viewId });
|
|
286
|
+
};
|
|
287
|
+
const onLeave = (e: PointerEvent): void => {
|
|
288
|
+
if (e.buttons !== 0) return;
|
|
289
|
+
if (store.get()?.viewId === viewId) store.set(null);
|
|
290
|
+
};
|
|
291
|
+
el.addEventListener('pointermove', onMove);
|
|
292
|
+
el.addEventListener('pointerleave', onLeave);
|
|
293
|
+
return () => {
|
|
294
|
+
el.removeEventListener('pointermove', onMove);
|
|
295
|
+
el.removeEventListener('pointerleave', onLeave);
|
|
296
|
+
};
|
|
297
|
+
}, [hostRef, store, viewId]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The pointer as a frame-local point, or `null` when there is none or it is
|
|
301
|
+
* over `viewId` itself. */
|
|
302
|
+
export function pointerElsewhere(
|
|
303
|
+
p: PointerWorldPos,
|
|
304
|
+
viewId: string,
|
|
305
|
+
frame: WorldFrame,
|
|
306
|
+
): Point | null {
|
|
307
|
+
if (!p || p.viewId === viewId) return null;
|
|
308
|
+
return frameLocalToWorld({ x: p.worldX, y: p.worldY }, frame);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export type { ViewportSize };
|
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type CSSProperties,
|
|
3
|
-
type ReactNode,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
} from 'react';
|
|
1
|
+
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
2
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
11
3
|
import { normalize2DView } from '../state/view';
|
|
12
|
-
import {
|
|
4
|
+
import { CameraContext, CameraInput, CameraScope, useCameraView } from './CameraInput';
|
|
13
5
|
import { CanvasStackContext } from './CanvasStackContext';
|
|
14
|
-
import {
|
|
6
|
+
import { LinkedCursor } from './LinkedCursor';
|
|
15
7
|
import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
|
|
16
|
-
import { usePanZoom } from './usePanZoom';
|
|
17
8
|
import { resolveFrame, type ViewportSize, type WorldSpec } from './worldSpec';
|
|
18
9
|
|
|
19
10
|
/** Props for `<CanvasStack>`. */
|
|
@@ -38,7 +29,8 @@ export interface CanvasStackProps {
|
|
|
38
29
|
|
|
39
30
|
/** Stacks one `<canvas>` per layer and drives them from a shared view, so a
|
|
40
31
|
* layer that changes rarely is not redrawn with one that changes every frame.
|
|
41
|
-
* Handles sizing
|
|
32
|
+
* Handles sizing and device pixel ratio; pan and zoom route through weasel's
|
|
33
|
+
* gesture dispatcher (`<CameraInput>`). */
|
|
42
34
|
export function CanvasStack({
|
|
43
35
|
layers,
|
|
44
36
|
view: viewProp,
|
|
@@ -92,31 +84,18 @@ export function CanvasStack({
|
|
|
92
84
|
|
|
93
85
|
const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
|
|
94
86
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// Bound by hand because React registers wheel listeners passive, where
|
|
108
|
-
// `preventDefault` is refused and the page scrolls under the zoom.
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
const el = containerRef.current;
|
|
111
|
-
if (!el) return;
|
|
112
|
-
const wheel = (e: WheelEvent): void => onWheelRef.current(e, el);
|
|
113
|
-
el.addEventListener('wheel', wheel, { passive: false });
|
|
114
|
-
if (wheelSlot) wheelSlot.current = wheel;
|
|
115
|
-
return () => {
|
|
116
|
-
el.removeEventListener('wheel', wheel);
|
|
117
|
-
if (wheelSlot?.current === wheel) wheelSlot.current = null;
|
|
118
|
-
};
|
|
119
|
-
}, [wheelSlot]);
|
|
87
|
+
const camera = useCameraView({
|
|
88
|
+
view: viewProp,
|
|
89
|
+
onViewChange,
|
|
90
|
+
frame,
|
|
91
|
+
hostRef: containerRef,
|
|
92
|
+
minZoom,
|
|
93
|
+
maxZoom,
|
|
94
|
+
});
|
|
95
|
+
const cameraCtx = useMemo(
|
|
96
|
+
() => ({ view: camera, frame, element: () => containerRef.current }),
|
|
97
|
+
[camera, frame],
|
|
98
|
+
);
|
|
120
99
|
useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
|
|
121
100
|
|
|
122
101
|
const ctxValue = useMemo(
|
|
@@ -139,25 +118,30 @@ export function CanvasStack({
|
|
|
139
118
|
};
|
|
140
119
|
|
|
141
120
|
return (
|
|
142
|
-
<
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
121
|
+
<CameraScope>
|
|
122
|
+
<CameraContext.Provider value={cameraCtx}>
|
|
123
|
+
<CanvasStackContext.Provider value={ctxValue}>
|
|
124
|
+
<CameraInput hostRef={containerRef} camera={camera} frame={frame} onTap={onHitTest} />
|
|
125
|
+
<div
|
|
126
|
+
ref={containerRef}
|
|
127
|
+
className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
|
|
128
|
+
style={containerStyle}
|
|
129
|
+
>
|
|
130
|
+
{layers.map((layer) => (
|
|
131
|
+
<canvas
|
|
132
|
+
key={layer.id}
|
|
133
|
+
ref={(el) => setCanvasRef(layer.id, el)}
|
|
134
|
+
className="lk-canvas-stack__canvas"
|
|
135
|
+
width={canvasPx.width}
|
|
136
|
+
height={canvasPx.height}
|
|
137
|
+
style={{ ...canvasCss, display: layer.visible ? 'block' : 'none' }}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
<LinkedCursor />
|
|
141
|
+
<div className="lk-canvas-stack__overlay">{children}</div>
|
|
142
|
+
</div>
|
|
143
|
+
</CanvasStackContext.Provider>
|
|
144
|
+
</CameraContext.Provider>
|
|
145
|
+
</CameraScope>
|
|
162
146
|
);
|
|
163
147
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.lk-linked-cursor {
|
|
2
|
+
position: absolute;
|
|
3
|
+
inset: 0;
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 100%;
|
|
6
|
+
overflow: visible;
|
|
7
|
+
pointer-events: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lk-linked-cursor__halo {
|
|
11
|
+
fill: rgb(0 0 0 / 60%);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.lk-linked-cursor__bar {
|
|
15
|
+
fill: var(--wzl-accent, #4c8dff);
|
|
16
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { crosshairRects, usePointerPosition } from '@weasel-js/core';
|
|
2
|
+
import { useContext } from 'react';
|
|
3
|
+
import { CameraContext, pointerElsewhere, STAGE_VIEW_ID } from './CameraInput';
|
|
4
|
+
|
|
5
|
+
/** `crosshairRects` returns its four arms in this order. */
|
|
6
|
+
const ARMS = ['left', 'right', 'up', 'down'] as const;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The other side of a linked cursor: a crosshair on the camera's own element
|
|
10
|
+
* wherever the shared pointer is over some other view — an overview, say. The
|
|
11
|
+
* same crosshair core paints on a weasel canvas.
|
|
12
|
+
*/
|
|
13
|
+
export function LinkedCursor() {
|
|
14
|
+
const camera = useContext(CameraContext);
|
|
15
|
+
const pointer = usePointerPosition();
|
|
16
|
+
if (!camera) return null;
|
|
17
|
+
const local = pointerElsewhere(pointer, STAGE_VIEW_ID, camera.frame);
|
|
18
|
+
if (!local) return null;
|
|
19
|
+
// `pointerElsewhere` hands back the instrument's world; the camera works
|
|
20
|
+
// frame-local, and the map is its own inverse.
|
|
21
|
+
const v = camera.view.get();
|
|
22
|
+
const x = (local.x - v.x) * v.scale.x;
|
|
23
|
+
const y = (local.y * camera.frame.yDir - v.y) * v.scale.y;
|
|
24
|
+
const { halo, bars } = crosshairRects(x, y, 1);
|
|
25
|
+
const arms = ARMS.map((name, i) => ({ name, halo: halo[i], bar: bars[i] }));
|
|
26
|
+
return (
|
|
27
|
+
<svg className="lk-linked-cursor" aria-hidden="true">
|
|
28
|
+
{arms.map(({ name, halo: r }) =>
|
|
29
|
+
r ? (
|
|
30
|
+
<rect
|
|
31
|
+
key={`halo-${name}`}
|
|
32
|
+
className="lk-linked-cursor__halo"
|
|
33
|
+
x={r.x}
|
|
34
|
+
y={r.y}
|
|
35
|
+
width={r.w}
|
|
36
|
+
height={r.h}
|
|
37
|
+
/>
|
|
38
|
+
) : null,
|
|
39
|
+
)}
|
|
40
|
+
{arms.map(({ name, bar: r }) =>
|
|
41
|
+
r ? (
|
|
42
|
+
<rect
|
|
43
|
+
key={`bar-${name}`}
|
|
44
|
+
className="lk-linked-cursor__bar"
|
|
45
|
+
x={r.x}
|
|
46
|
+
y={r.y}
|
|
47
|
+
width={r.w}
|
|
48
|
+
height={r.h}
|
|
49
|
+
/>
|
|
50
|
+
) : null,
|
|
51
|
+
)}
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/canvas/Stage.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { type CSSProperties, type ReactNode,
|
|
1
|
+
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
2
2
|
import type { ViewTransform } from '../instrument/types';
|
|
3
3
|
import { normalize2DView } from '../state/view';
|
|
4
4
|
import { useSurfaceOptional } from '../surface/useSurfaceTile';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import type
|
|
5
|
+
import { CameraContext, CameraInput, CameraScope, useCameraView } from './CameraInput';
|
|
6
|
+
import { LinkedCursor } from './LinkedCursor';
|
|
7
|
+
import { DEFAULT_FRAME, type ViewportSize } from './worldSpec';
|
|
8
8
|
|
|
9
9
|
/** Props for `<Stage>`. */
|
|
10
10
|
export interface StageProps {
|
|
@@ -43,7 +43,8 @@ export function fitStage(size: ViewportSize, viewport: ViewportSize): ViewTransf
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* DOM content of a fixed size, panned and zoomed by a camera — what
|
|
46
|
-
* `<CanvasStack>` is for layers drawn to a canvas.
|
|
46
|
+
* `<CanvasStack>` is for layers drawn to a canvas. Pan and zoom route through
|
|
47
|
+
* weasel's gesture dispatcher (`<CameraInput>`). The content is laid out at
|
|
47
48
|
* its own size and transformed, so everything inside keeps its layout and a
|
|
48
49
|
* measurement of any element in it reports where it is actually drawn.
|
|
49
50
|
*/
|
|
@@ -61,24 +62,23 @@ export function Stage({
|
|
|
61
62
|
const view = normalize2DView(viewProp);
|
|
62
63
|
const host = useRef<HTMLDivElement | null>(null);
|
|
63
64
|
const surface = useSurfaceOptional();
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}, [wheelSlot]);
|
|
65
|
+
const camera = useCameraView({
|
|
66
|
+
view: viewProp,
|
|
67
|
+
onViewChange,
|
|
68
|
+
frame: DEFAULT_FRAME,
|
|
69
|
+
hostRef: host,
|
|
70
|
+
minZoom,
|
|
71
|
+
maxZoom,
|
|
72
|
+
});
|
|
73
|
+
const cameraCtx = useMemo(
|
|
74
|
+
() => ({
|
|
75
|
+
view: camera,
|
|
76
|
+
frame: DEFAULT_FRAME,
|
|
77
|
+
element: () => host.current,
|
|
78
|
+
content: { x: 0, y: 0, width: size.width, height: size.height },
|
|
79
|
+
}),
|
|
80
|
+
[camera, size.width, size.height],
|
|
81
|
+
);
|
|
82
82
|
|
|
83
83
|
const onResizeRef = useRef(onResize);
|
|
84
84
|
onResizeRef.current = onResize;
|
|
@@ -108,7 +108,7 @@ export function Stage({
|
|
|
108
108
|
// The camera changes every frame of a pan, and a transform has nowhere to
|
|
109
109
|
// live but the element's own style: set as custom properties the stylesheet
|
|
110
110
|
// reads.
|
|
111
|
-
const
|
|
111
|
+
const cameraVars = {
|
|
112
112
|
['--lk-stage-x' as string]: `${view.pan.x}px`,
|
|
113
113
|
['--lk-stage-y' as string]: `${view.pan.y}px`,
|
|
114
114
|
['--lk-stage-zoom' as string]: String(view.zoom),
|
|
@@ -117,18 +117,23 @@ export function Stage({
|
|
|
117
117
|
} as CSSProperties;
|
|
118
118
|
|
|
119
119
|
return (
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
host
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
<CameraScope>
|
|
121
|
+
<CameraContext.Provider value={cameraCtx}>
|
|
122
|
+
<CameraInput hostRef={host} camera={camera} frame={DEFAULT_FRAME} />
|
|
123
|
+
<div
|
|
124
|
+
ref={(el) => {
|
|
125
|
+
host.current = el;
|
|
126
|
+
if (hostRef) hostRef.current = el;
|
|
127
|
+
}}
|
|
128
|
+
className="lk-stage"
|
|
129
|
+
>
|
|
130
|
+
<div className="lk-stage__content" style={cameraVars}>
|
|
131
|
+
{children}
|
|
132
|
+
</div>
|
|
133
|
+
<LinkedCursor />
|
|
134
|
+
{overlay}
|
|
135
|
+
</div>
|
|
136
|
+
</CameraContext.Provider>
|
|
137
|
+
</CameraScope>
|
|
133
138
|
);
|
|
134
139
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { View } from '@weasel-js/core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
|
|
4
|
+
import { worldToScreen } from './canvasCoords';
|
|
5
|
+
import type { WorldFrame } from './worldSpec';
|
|
6
|
+
|
|
7
|
+
const UP: WorldFrame = { originPx: { x: 200, y: 150 }, yDir: -1 };
|
|
8
|
+
const vt = { zoom: 2.5, pan: { x: 30, y: -40 } };
|
|
9
|
+
|
|
10
|
+
/** Where a weasel View puts a frame-local point. */
|
|
11
|
+
const project = (v: View, p: { x: number; y: number }) => ({
|
|
12
|
+
x: (p.x - v.x) * v.scale.x,
|
|
13
|
+
y: (p.y - v.y) * v.scale.y,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('camera as a weasel View', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
['default frame', undefined],
|
|
19
|
+
['moved origin, y up', UP],
|
|
20
|
+
] as const)('places every world point where labkit does (%s)', (_, frame) => {
|
|
21
|
+
const v = toCameraView(vt, frame);
|
|
22
|
+
for (const w of [
|
|
23
|
+
{ x: 0, y: 0 },
|
|
24
|
+
{ x: 13, y: -7 },
|
|
25
|
+
{ x: -120, y: 44 },
|
|
26
|
+
]) {
|
|
27
|
+
const local = frameLocalToWorld(w, frame);
|
|
28
|
+
const a = project(v, local);
|
|
29
|
+
const b = worldToScreen(w, vt, frame);
|
|
30
|
+
expect(a.x).toBeCloseTo(b.x);
|
|
31
|
+
expect(a.y).toBeCloseTo(b.y);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('round-trips', () => {
|
|
36
|
+
const back = fromCameraView(toCameraView(vt, UP), UP);
|
|
37
|
+
expect(back.zoom).toBeCloseTo(vt.zoom);
|
|
38
|
+
expect(back.pan.x).toBeCloseTo(vt.pan.x);
|
|
39
|
+
expect(back.pan.y).toBeCloseTo(vt.pan.y);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('clampZoomAbout', () => {
|
|
44
|
+
const prev: View = { x: 0, y: 0, scale: { x: 1, y: 1 } };
|
|
45
|
+
it('holds the anchor of a zoom that hits the bound', () => {
|
|
46
|
+
// A 4x zoom about screen (100, 50): world under it stays (100, 50).
|
|
47
|
+
const next: View = { x: 75, y: 37.5, scale: { x: 4, y: 4 } };
|
|
48
|
+
const out = clampZoomAbout(prev, next, 0.5, 2);
|
|
49
|
+
expect(out.scale.x).toBe(2);
|
|
50
|
+
expect(100 / out.scale.x + out.x).toBeCloseTo(100);
|
|
51
|
+
expect(50 / out.scale.y + out.y).toBeCloseTo(50);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('passes a zoom inside the bounds and a pan through', () => {
|
|
55
|
+
const inside: View = { x: 10, y: 5, scale: { x: 1.5, y: 1.5 } };
|
|
56
|
+
expect(clampZoomAbout(prev, inside, 0.5, 2)).toBe(inside);
|
|
57
|
+
const pan: View = { x: 10, y: 5, scale: { x: 1, y: 1 } };
|
|
58
|
+
expect(clampZoomAbout(prev, pan, 0.5, 2)).toBe(pan);
|
|
59
|
+
});
|
|
60
|
+
});
|