@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
package/dist/undo/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { az as UndoCapability } from '../_dts/types-B7yBTTF9.js';
|
|
2
|
+
import '@weasel-js/core';
|
|
2
3
|
import 'react';
|
|
3
4
|
import '../_dts/PrefsRow.d-DFpPIeIC.js';
|
|
4
5
|
import '@weasel-js/theme';
|
|
5
|
-
import '
|
|
6
|
-
import '../_dts/types-B7y8hX1D.js';
|
|
6
|
+
import '../_dts/types-DTuViBvc.js';
|
|
7
7
|
import '../_dts/types-DJ79Tg5J.js';
|
|
8
8
|
|
|
9
9
|
/** A subscriber to a named event. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "React widgets for building self-contained interactive lab pages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "orochi235",
|
|
@@ -74,6 +74,10 @@
|
|
|
74
74
|
"types": "./dist/job/index.d.ts",
|
|
75
75
|
"import": "./dist/job/index.js"
|
|
76
76
|
},
|
|
77
|
+
"./overview": {
|
|
78
|
+
"types": "./dist/overview/index.d.ts",
|
|
79
|
+
"import": "./dist/overview/index.js"
|
|
80
|
+
},
|
|
77
81
|
"./weasel-ui": {
|
|
78
82
|
"types": "./dist/passthrough/weasel-ui.d.ts",
|
|
79
83
|
"import": "./dist/passthrough/weasel-ui.js"
|
|
@@ -111,17 +115,17 @@
|
|
|
111
115
|
"dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
|
|
112
116
|
},
|
|
113
117
|
"peerDependencies": {
|
|
114
|
-
"@weasel-js/core": "1.6.
|
|
115
|
-
"@weasel-js/theme": "1.6.
|
|
118
|
+
"@weasel-js/core": "1.6.1",
|
|
119
|
+
"@weasel-js/theme": "1.6.1",
|
|
116
120
|
"react": "^19.0.0",
|
|
117
121
|
"react-dom": "^19.0.0"
|
|
118
122
|
},
|
|
119
123
|
"dependencies": {
|
|
120
|
-
"@weasel-js/geom": "1.6.
|
|
121
|
-
"@weasel-js/kernel3d": "1.6.
|
|
122
|
-
"@weasel-js/loupe": "1.6.
|
|
123
|
-
"@weasel-js/svg": "1.6.
|
|
124
|
-
"@weasel-js/ui": "1.6.
|
|
124
|
+
"@weasel-js/geom": "1.6.1",
|
|
125
|
+
"@weasel-js/kernel3d": "1.6.1",
|
|
126
|
+
"@weasel-js/loupe": "1.6.1",
|
|
127
|
+
"@weasel-js/svg": "1.6.1",
|
|
128
|
+
"@weasel-js/ui": "1.6.1",
|
|
125
129
|
"earcut": "2.2.4",
|
|
126
130
|
"polygon-clipping": "^0.15.7",
|
|
127
131
|
"react-aria-components": "^1.5.0",
|
|
@@ -132,7 +136,7 @@
|
|
|
132
136
|
"@biomejs/biome": "^2.4.12",
|
|
133
137
|
"@rollup/plugin-alias": "^6.0.0",
|
|
134
138
|
"@testing-library/jest-dom": "^6.6.0",
|
|
135
|
-
"@weasel-js/modes": "1.6.
|
|
139
|
+
"@weasel-js/modes": "1.6.1",
|
|
136
140
|
"less": "^4.2.0",
|
|
137
141
|
"rollup-plugin-dts": "^6.4.1",
|
|
138
142
|
"tsx": "^4.19.0",
|
package/src/annotations/index.ts
CHANGED
|
@@ -294,3 +294,29 @@ describe('the annotation store', () => {
|
|
|
294
294
|
expect(svg).toContain('#222222');
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
describe('paintedMarks', () => {
|
|
299
|
+
it("hands over a target's marks in world units with their resolved style", () => {
|
|
300
|
+
let config: Record<string, string> = { angle: 'iso', shading: 'outline' };
|
|
301
|
+
const store = createAnnotationStore({
|
|
302
|
+
targets: () => TARGETS,
|
|
303
|
+
meaning: { statuses: [{ id: 'open', label: 'Open', color: '#123456' }] },
|
|
304
|
+
config: () => config,
|
|
305
|
+
});
|
|
306
|
+
store.add(RING, config);
|
|
307
|
+
const [painted] = store.paintedMarks('naive');
|
|
308
|
+
expect(painted?.mark.data.kind).toBe('rect');
|
|
309
|
+
expect(painted?.mark.pose.x).toBeCloseTo(0.3322 * 256);
|
|
310
|
+
expect(painted?.style).toEqual({ color: '#123456', stale: false });
|
|
311
|
+
|
|
312
|
+
// A config the mark no longer describes draws it dashed, as the pane does.
|
|
313
|
+
config = { angle: 'top', shading: 'outline' };
|
|
314
|
+
expect(store.paintedMarks('naive')[0]?.style.stale).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('answers nothing for a target with no marks or no declaration', () => {
|
|
318
|
+
const store = makeStore();
|
|
319
|
+
expect(store.paintedMarks('occt')).toEqual([]);
|
|
320
|
+
expect(store.paintedMarks('nowhere')).toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
});
|
package/src/annotations/store.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
sceneFromJSON,
|
|
7
7
|
} from '@weasel-js/core';
|
|
8
8
|
import { captureTarget } from './capture';
|
|
9
|
+
import { type MarkDrawOptions, resolveMarkStyle } from './drawOne';
|
|
9
10
|
import type { WorldRect } from './frac';
|
|
10
11
|
import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
|
|
11
12
|
import { MarkHistory } from './history';
|
|
@@ -123,6 +124,14 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
123
124
|
targets().find((t) => t.id === id);
|
|
124
125
|
|
|
125
126
|
const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
|
|
127
|
+
/** How a target's marks are drawn right now — one answer for the pane, an
|
|
128
|
+
* export and an overview, so none can disagree on a color or a dash. */
|
|
129
|
+
const drawOptionsFor = (info: AnnotationTargetInfo): MarkDrawOptions => ({
|
|
130
|
+
content: info.content,
|
|
131
|
+
positionDependsOn: info.positionDependsOn,
|
|
132
|
+
config: opts.config?.(),
|
|
133
|
+
meaning: typeof opts.meaning === 'function' ? opts.meaning() : opts.meaning,
|
|
134
|
+
});
|
|
126
135
|
|
|
127
136
|
const project = (target: string, node: string): Annotation | undefined => {
|
|
128
137
|
const found = scenes.get(target)?.get(asNodeId(node));
|
|
@@ -304,6 +313,21 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
304
313
|
if (found) found.scene.remove(asNodeId(found.node.id));
|
|
305
314
|
},
|
|
306
315
|
|
|
316
|
+
paintedMarks(target) {
|
|
317
|
+
const info = targetOf(target);
|
|
318
|
+
const scene = scenes.get(target);
|
|
319
|
+
if (!info || !scene) return [];
|
|
320
|
+
const draw = drawOptionsFor(info);
|
|
321
|
+
return [...scene.renderOrder()].flatMap((id) => {
|
|
322
|
+
const node = scene.get(asNodeId(String(id)));
|
|
323
|
+
if (!node) return [];
|
|
324
|
+
const data = node.data as AnnotationData;
|
|
325
|
+
return [
|
|
326
|
+
{ mark: { pose: node.pose as MarkPose, data }, style: resolveMarkStyle(data, draw) },
|
|
327
|
+
];
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
|
|
307
331
|
async capture(target: string, captureOpts?: CaptureOptions) {
|
|
308
332
|
const info = targetOf(target);
|
|
309
333
|
if (!info) throw new Error(`[labkit] no annotation target called '${target}'`);
|
|
@@ -311,12 +335,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
311
335
|
{
|
|
312
336
|
target,
|
|
313
337
|
scene: sceneFor(target),
|
|
314
|
-
draw:
|
|
315
|
-
content: info.content,
|
|
316
|
-
positionDependsOn: info.positionDependsOn,
|
|
317
|
-
config: opts.config?.(),
|
|
318
|
-
meaning: typeof opts.meaning === 'function' ? opts.meaning() : opts.meaning,
|
|
319
|
-
},
|
|
338
|
+
draw: drawOptionsFor(info),
|
|
320
339
|
base: info.base,
|
|
321
340
|
onWarn: (message) => console.warn(`[labkit] capture: ${message}`),
|
|
322
341
|
},
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { KIT_SHAPE_KINDS } from '@weasel-js/core';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import type { InstrumentList } from '../instrument/types';
|
|
4
|
+
import { resolveLabTool } from '../tools/labTool';
|
|
5
|
+
import { ANNOTATION_TOOLS, annotationToolInfo, labAnnotationTools } from './toolMap';
|
|
6
|
+
import type { AnnotationKind, AnnotationToolId } from './types';
|
|
5
7
|
|
|
6
8
|
const KINDS: AnnotationKind[] = ['stroke', 'line', 'arrow', 'rect', 'ellipse', 'text'];
|
|
7
9
|
|
|
@@ -38,3 +40,36 @@ describe('the annotation tool table', () => {
|
|
|
38
40
|
expect(annotationToolInfo('hand')).toBeUndefined();
|
|
39
41
|
});
|
|
40
42
|
});
|
|
43
|
+
|
|
44
|
+
describe('the tools a lab rail carries', () => {
|
|
45
|
+
const ids = (instruments: InstrumentList) => labAnnotationTools(instruments).map((t) => t.id);
|
|
46
|
+
const inst = (annotations?: { tools?: AnnotationToolId[] }) =>
|
|
47
|
+
({
|
|
48
|
+
annotations: annotations && { targets: () => [], ...annotations },
|
|
49
|
+
}) as unknown as InstrumentList[number];
|
|
50
|
+
|
|
51
|
+
it('carries none when no instrument annotates', () => {
|
|
52
|
+
expect(ids([inst()])).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('carries every tool for an instrument that names none', () => {
|
|
56
|
+
expect(ids([inst({})])).toEqual(ANNOTATION_TOOLS.map((t) => t.id));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('carries only the named tools, in the kit order', () => {
|
|
60
|
+
expect(ids([inst({ tools: ['select', 'pointer'] })])).toEqual(['pointer', 'select']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('carries the union across annotating instruments', () => {
|
|
64
|
+
expect(ids([inst({ tools: ['pointer'] }), inst({ tools: ['rect'] }), inst()])).toEqual([
|
|
65
|
+
'pointer',
|
|
66
|
+
'rect',
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('starts the lab in pointer when the rail has it, else in its first tool', () => {
|
|
71
|
+
expect(resolveLabTool(null, [inst({ tools: ['select', 'pointer'] })])).toBe('pointer');
|
|
72
|
+
expect(resolveLabTool(null, [inst({ tools: ['text', 'select'] })])).toBe('select');
|
|
73
|
+
expect(resolveLabTool(null, [inst()])).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
SelectIcon,
|
|
10
10
|
TextIcon,
|
|
11
11
|
} from '@weasel-js/ui';
|
|
12
|
+
import type { InstrumentList } from '../instrument/types';
|
|
12
13
|
import type { TrialTool } from '../tools/types';
|
|
13
14
|
import type { AnnotationKind } from './types';
|
|
14
15
|
|
|
@@ -46,6 +47,18 @@ export const ANNOTATION_TOOLS: readonly TrialTool[] = [
|
|
|
46
47
|
{ id: 'text', label: 'Text', icon: TextIcon, group: 'annotate' },
|
|
47
48
|
];
|
|
48
49
|
|
|
50
|
+
/** The annotation tools a lab's rail carries for these instruments: none
|
|
51
|
+
* without an annotating instrument, else the union of what each asks for. */
|
|
52
|
+
export function labAnnotationTools(instruments: InstrumentList): readonly TrialTool[] {
|
|
53
|
+
const wanted = new Set<string>();
|
|
54
|
+
for (const i of instruments) {
|
|
55
|
+
if (i.annotations == null) continue;
|
|
56
|
+
if (i.annotations.tools == null) return ANNOTATION_TOOLS;
|
|
57
|
+
for (const id of i.annotations.tools) wanted.add(id);
|
|
58
|
+
}
|
|
59
|
+
return ANNOTATION_TOOLS.filter((t) => wanted.has(t.id));
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
/** What `id` drives, or undefined for a tool that is not one of these. */
|
|
50
63
|
export function annotationToolInfo(id: string | null): AnnotationToolInfo | undefined {
|
|
51
64
|
return id === null ? undefined : TOOLS[id];
|
package/src/annotations/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RefObject } from 'react';
|
|
2
2
|
import type { ViewTransform } from '../instrument/types';
|
|
3
3
|
import type { TrialInfo } from '../state/types';
|
|
4
|
+
import type { MarkStyle, PaintableMark } from './paint';
|
|
4
5
|
import type { MarkScene } from './store';
|
|
5
6
|
|
|
6
7
|
/** A point in fractions of a target's content box. */
|
|
@@ -160,10 +161,25 @@ export interface AnnotationStorage {
|
|
|
160
161
|
|
|
161
162
|
/** Declares that an instrument accepts marks: which regions take them,
|
|
162
163
|
* optionally what a mark is allowed to mean, and optionally where they live. */
|
|
164
|
+
/** The annotation tools a lab's rail can carry. */
|
|
165
|
+
export type AnnotationToolId =
|
|
166
|
+
| 'pointer'
|
|
167
|
+
| 'select'
|
|
168
|
+
| 'stroke'
|
|
169
|
+
| 'line'
|
|
170
|
+
| 'arrow'
|
|
171
|
+
| 'rect'
|
|
172
|
+
| 'ellipse'
|
|
173
|
+
| 'text';
|
|
174
|
+
|
|
163
175
|
export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
164
176
|
/** `trial` is which trial is asking: a declaration made once per instrument
|
|
165
177
|
* is called once per trial, and its targets are that trial's own. */
|
|
166
178
|
targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
|
|
179
|
+
/** Which annotation tools the lab's rail offers. The rail is shared, so it
|
|
180
|
+
* carries every tool any annotating instrument asks for, in the kit's own
|
|
181
|
+
* order; an instrument that leaves this unset asks for all of them. */
|
|
182
|
+
tools?: readonly AnnotationToolId[];
|
|
167
183
|
meaning?: AnnotationMeaning;
|
|
168
184
|
/** An instrument replacing this one under a live trial must pass the same
|
|
169
185
|
* object: the trial keeps the marks it loaded from the first. */
|
|
@@ -183,6 +199,12 @@ export interface SerializedAnnotations {
|
|
|
183
199
|
scenes: Record<string, unknown>;
|
|
184
200
|
}
|
|
185
201
|
|
|
202
|
+
/** One mark, as `AnnotationsApi.paintedMarks` hands it over. */
|
|
203
|
+
export interface PaintedMark {
|
|
204
|
+
mark: PaintableMark;
|
|
205
|
+
style: MarkStyle;
|
|
206
|
+
}
|
|
207
|
+
|
|
186
208
|
/** Everything a host can ask or tell labkit about the marks on its targets. */
|
|
187
209
|
export interface AnnotationsApi {
|
|
188
210
|
/** The scene a target's marks live in, created on first ask. One per target
|
|
@@ -222,6 +244,11 @@ export interface AnnotationsApi {
|
|
|
222
244
|
undo(): boolean;
|
|
223
245
|
redo(): boolean;
|
|
224
246
|
|
|
247
|
+
/** A target's marks as its pane paints them, in scene order: geometry in
|
|
248
|
+
* the target's content box, each with the style its status and staleness
|
|
249
|
+
* resolve to. For a painter outside the pane — an overview. */
|
|
250
|
+
paintedMarks(target: string): PaintedMark[];
|
|
251
|
+
|
|
225
252
|
/** Export a target's picture with its marks on it. Rejects on an id the
|
|
226
253
|
* instrument does not declare. */
|
|
227
254
|
capture(target: string, opts?: CaptureOptions): Promise<CaptureResult>;
|
package/src/canvas/AGENTS.md
CHANGED
|
@@ -6,18 +6,16 @@ For a renderer labkit does not own — three.js, raw WebGL, anything with its ow
|
|
|
6
6
|
render loop — see `src/surface/AGENTS.md` instead. `CanvasStack` is 2D and
|
|
7
7
|
schedules its own layers; a foreign renderer wants rects and dirtiness only.
|
|
8
8
|
|
|
9
|
-
`useOrbit` also lives here, as the 3D peer of `usePanZoom`. It produces a trial
|
|
10
|
-
view (`{ yaw, pitch, distance, target }`) rather than a matrix — turning that
|
|
11
|
-
into a camera is the host's job.
|
|
12
|
-
|
|
13
9
|
## Files
|
|
14
10
|
|
|
15
11
|
| File | Role |
|
|
16
12
|
|---|---|
|
|
17
|
-
| `CanvasStack.tsx` | Layered `<canvas>` container; owns sizing
|
|
13
|
+
| `CanvasStack.tsx` | Layered `<canvas>` container; owns sizing and per-layer canvas refs |
|
|
18
14
|
| `CanvasStackContext.ts` | React context exposing the current `view` to descendants |
|
|
19
15
|
| `useLayerScheduler.ts` | DPR-aware rAF scheduler; redraws dirty layers on view/state changes |
|
|
20
|
-
| `
|
|
16
|
+
| `CameraInput.tsx` | Pan, zoom and tap routed through weasel's gesture dispatcher; `CameraContext`; publishes the pointer |
|
|
17
|
+
| `cameraView.ts` | The camera as a weasel `View` over frame-local coordinates, and the anchored zoom clamp |
|
|
18
|
+
| `LinkedCursor.tsx` | The crosshair on the camera's element while the pointer is over another view |
|
|
21
19
|
| `camera.ts` | `zoomAt` (fixed-point zoom) and `centerOn` (put a world point at a viewport's middle) |
|
|
22
20
|
| `canvasCoords.ts` | Pure `screenToWorld` / `worldToScreen` helpers |
|
|
23
21
|
| `worldSpec.ts` | The instrument's declared coordinate system, and the camera derived from it |
|
|
@@ -32,8 +30,8 @@ interface CanvasStackProps {
|
|
|
32
30
|
onViewChange: (v: ViewTransform) => void;
|
|
33
31
|
worldSpec?: WorldSpec; // origin + y direction; default top-left, y down
|
|
34
32
|
onResize?: (size: ViewportSize) => void;
|
|
35
|
-
minZoom?: number; // default 0.1
|
|
36
|
-
maxZoom?: number; // default 32
|
|
33
|
+
minZoom?: number; // default 0.1; the opening zoom stays reachable
|
|
34
|
+
maxZoom?: number; // default 32
|
|
37
35
|
width?: number | string; // default '100%'
|
|
38
36
|
height?: number | string; // default '100%'
|
|
39
37
|
className?: string;
|
|
@@ -93,11 +91,11 @@ convention: origin at the element's top-left, y downward.
|
|
|
93
91
|
`resolveFrame(spec, size)` turns a spec plus a measured viewport into a
|
|
94
92
|
`WorldFrame` — `{ originPx, yDir }` — which is what the rest of the directory
|
|
95
93
|
actually consumes. `CanvasStack` owns the measurement, so it owns the frame and
|
|
96
|
-
hands it to `
|
|
94
|
+
hands it to the camera (`useCameraView`), to the scheduler, and to every `render`.
|
|
97
95
|
|
|
98
96
|
**Every world↔screen path must go through the frame.** There are four, and each
|
|
99
97
|
one is a silent wrong answer if it is missed: `worldToScreen` / `screenToWorld`,
|
|
100
|
-
the camera (`applyCamera`), the
|
|
98
|
+
the camera (`applyCamera`), the camera as a weasel `View` (`cameraView.ts`), and
|
|
101
99
|
`DragDropRuntime`'s drop position. A miss produces geometry that is off by a
|
|
102
100
|
constant, or a wheel that drifts — never an error.
|
|
103
101
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A labkit camera on weasel's dispatcher: drag pans by the screen delta, the
|
|
3
|
+
* zoom range holds with the opening zoom kept reachable, and the pointer is
|
|
4
|
+
* published in the instrument's world.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { act, render } from '@testing-library/react';
|
|
8
|
+
import { createPointerStore, PointerContextProvider } from '@weasel-js/core';
|
|
9
|
+
import { useState } from 'react';
|
|
10
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import type { ViewTransform } from '../instrument/types';
|
|
12
|
+
import { CanvasStack } from './CanvasStack';
|
|
13
|
+
import { Stage } from './Stage';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
17
|
+
() => null,
|
|
18
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
19
|
+
const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
|
|
20
|
+
proto.setPointerCapture = vi.fn();
|
|
21
|
+
proto.releasePointerCapture = vi.fn();
|
|
22
|
+
proto.hasPointerCapture = vi.fn(() => true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function lastOf<T>(list: readonly T[]): T {
|
|
26
|
+
const item = list[list.length - 1];
|
|
27
|
+
if (item === undefined) throw new Error('nothing recorded');
|
|
28
|
+
return item;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
|
|
32
|
+
el.dispatchEvent(
|
|
33
|
+
new PointerEvent(type, {
|
|
34
|
+
bubbles: true,
|
|
35
|
+
cancelable: true,
|
|
36
|
+
pointerId: 1,
|
|
37
|
+
clientX: x,
|
|
38
|
+
clientY: y,
|
|
39
|
+
button: 0,
|
|
40
|
+
buttons,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mountStage(initial: ViewTransform, opts: { minZoom?: number; maxZoom?: number } = {}) {
|
|
46
|
+
const seen: ViewTransform[] = [];
|
|
47
|
+
function Host() {
|
|
48
|
+
const [view, setView] = useState(initial);
|
|
49
|
+
return (
|
|
50
|
+
<Stage
|
|
51
|
+
size={{ width: 100, height: 100 }}
|
|
52
|
+
view={view}
|
|
53
|
+
onViewChange={(v) => {
|
|
54
|
+
seen.push(v);
|
|
55
|
+
setView(v);
|
|
56
|
+
}}
|
|
57
|
+
{...opts}
|
|
58
|
+
>
|
|
59
|
+
<div />
|
|
60
|
+
</Stage>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const { container } = render(<Host />);
|
|
64
|
+
const host = container.querySelector('.lk-stage') as HTMLElement;
|
|
65
|
+
return { host, seen };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('camera input', () => {
|
|
69
|
+
it('pans by the screen delta of a drag', () => {
|
|
70
|
+
const { host, seen } = mountStage({ zoom: 2, pan: { x: 10, y: 20 } });
|
|
71
|
+
act(() => {
|
|
72
|
+
pointer(host, 'pointerdown', 50, 50);
|
|
73
|
+
pointer(host, 'pointermove', 60, 55);
|
|
74
|
+
pointer(host, 'pointermove', 80, 70);
|
|
75
|
+
pointer(host, 'pointerup', 80, 70, 0);
|
|
76
|
+
});
|
|
77
|
+
const last = lastOf(seen);
|
|
78
|
+
expect(last.zoom).toBeCloseTo(2);
|
|
79
|
+
expect(last.pan.x).toBeCloseTo(40);
|
|
80
|
+
expect(last.pan.y).toBeCloseTo(40);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('stops the zoom at the bound, anchored on the pointer', () => {
|
|
84
|
+
const { host, seen } = mountStage({ zoom: 1, pan: { x: 0, y: 0 } }, { maxZoom: 1.2 });
|
|
85
|
+
act(() => {
|
|
86
|
+
host.dispatchEvent(
|
|
87
|
+
new WheelEvent('wheel', {
|
|
88
|
+
bubbles: true,
|
|
89
|
+
cancelable: true,
|
|
90
|
+
deltaY: -2000,
|
|
91
|
+
clientX: 40,
|
|
92
|
+
clientY: 30,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
const last = lastOf(seen);
|
|
97
|
+
expect(last.zoom).toBeCloseTo(1.2);
|
|
98
|
+
// The world point under (40, 30) stayed there.
|
|
99
|
+
expect((40 - last.pan.x) / last.zoom).toBeCloseTo(40);
|
|
100
|
+
expect((30 - last.pan.y) / last.zoom).toBeCloseTo(30);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('keeps the opening zoom reachable past a narrower range', () => {
|
|
104
|
+
const { host, seen } = mountStage({ zoom: 4, pan: { x: 0, y: 0 } }, { maxZoom: 2 });
|
|
105
|
+
act(() => {
|
|
106
|
+
host.dispatchEvent(
|
|
107
|
+
new WheelEvent('wheel', {
|
|
108
|
+
bubbles: true,
|
|
109
|
+
cancelable: true,
|
|
110
|
+
deltaY: 100,
|
|
111
|
+
clientX: 0,
|
|
112
|
+
clientY: 0,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
// Zooming out from 4 lands between 2 and 4, not snapped to 2.
|
|
117
|
+
expect(lastOf(seen).zoom).toBeGreaterThan(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("publishes the pointer in the instrument's world as the stage", () => {
|
|
121
|
+
const store = createPointerStore();
|
|
122
|
+
const { container } = render(
|
|
123
|
+
<PointerContextProvider store={store}>
|
|
124
|
+
<CanvasStack
|
|
125
|
+
layers={[]}
|
|
126
|
+
view={{ zoom: 2, pan: { x: 0, y: 0 } }}
|
|
127
|
+
onViewChange={() => {}}
|
|
128
|
+
worldSpec={{ yAxis: 'up' }}
|
|
129
|
+
/>
|
|
130
|
+
</PointerContextProvider>,
|
|
131
|
+
);
|
|
132
|
+
const host = container.querySelector('.lk-canvas-stack') as HTMLElement;
|
|
133
|
+
act(() => pointer(host, 'pointermove', 40, 30, 0));
|
|
134
|
+
expect(store.get()).toEqual({ worldX: 20, worldY: -15, viewId: 'stage' });
|
|
135
|
+
});
|
|
136
|
+
});
|