@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.
Files changed (162) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-BoELAMsM.d.ts} +2 -2
  4. package/dist/_dts/{index-DINmCuLA.d.ts → PropertyField.d-D0ndfIIt.d.ts} +34 -83
  5. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Cr8-zoup.d.ts} +4 -5
  6. package/dist/_dts/{usePanZoom-Bq73pwFe.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  7. package/dist/_dts/{index-B0--8PEM.d.ts → index-CKyX0ME_.d.ts} +12 -13
  8. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-B7yBTTF9.d.ts} +699 -649
  9. package/dist/_dts/{types-B7y8hX1D.d.ts → types-DTuViBvc.d.ts} +20 -1
  10. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  11. package/dist/canvas/index.d.ts +39 -7
  12. package/dist/canvas/index.js +5 -3
  13. package/dist/chrome/index.d.ts +4 -5
  14. package/dist/chrome/index.js +8 -6
  15. package/dist/chunk-24W5VEJW.js +18 -0
  16. package/dist/chunk-24W5VEJW.js.map +1 -0
  17. package/dist/chunk-3RY6WL5T.js +38 -0
  18. package/dist/chunk-3RY6WL5T.js.map +1 -0
  19. package/dist/{chunk-H5VIV7MQ.js → chunk-4TT3PNPR.js} +60 -51
  20. package/dist/chunk-4TT3PNPR.js.map +1 -0
  21. package/dist/{chunk-ZORVNOKX.js → chunk-6FLXL3I7.js} +9 -145
  22. package/dist/chunk-6FLXL3I7.js.map +1 -0
  23. package/dist/{chunk-HFRE4QQV.js → chunk-ASOK76ZH.js} +53 -22
  24. package/dist/chunk-ASOK76ZH.js.map +1 -0
  25. package/dist/chunk-AWQG7FCC.js +19 -0
  26. package/dist/chunk-AWQG7FCC.js.map +1 -0
  27. package/dist/chunk-CGHF6RZV.js +300 -0
  28. package/dist/chunk-CGHF6RZV.js.map +1 -0
  29. package/dist/chunk-DWMSB4CA.js +228 -0
  30. package/dist/chunk-DWMSB4CA.js.map +1 -0
  31. package/dist/{chunk-USO6TERP.js → chunk-GHGO52SO.js} +11 -9
  32. package/dist/chunk-GHGO52SO.js.map +1 -0
  33. package/dist/{chunk-MOLIUNVH.js → chunk-HKV7LRHY.js} +4 -4
  34. package/dist/{chunk-MOLIUNVH.js.map → chunk-HKV7LRHY.js.map} +1 -1
  35. package/dist/{chunk-VQ47KWD5.js → chunk-IRTGUQ5O.js} +5 -4
  36. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  37. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  38. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  39. package/dist/chunk-KHK5FGER.js +145 -0
  40. package/dist/chunk-KHK5FGER.js.map +1 -0
  41. package/dist/chunk-NEXR37YJ.js +144 -0
  42. package/dist/chunk-NEXR37YJ.js.map +1 -0
  43. package/dist/{chunk-BVL7Y574.js → chunk-O6FFFOHS.js} +14 -6
  44. package/dist/chunk-O6FFFOHS.js.map +1 -0
  45. package/dist/chunk-QGMOA2YE.js +731 -0
  46. package/dist/chunk-QGMOA2YE.js.map +1 -0
  47. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  48. package/dist/chunk-S4NCI32V.js.map +1 -0
  49. package/dist/{chunk-3FI7Y4R4.js → chunk-SLAQPUI4.js} +4 -4
  50. package/dist/{chunk-3FI7Y4R4.js.map → chunk-SLAQPUI4.js.map} +1 -1
  51. package/dist/{chunk-AWH3SRPW.js → chunk-TJ77A4A3.js} +3 -31
  52. package/dist/chunk-TJ77A4A3.js.map +1 -0
  53. package/dist/{chunk-WUJXJC5O.js → chunk-TYN6MLXX.js} +691 -1395
  54. package/dist/chunk-TYN6MLXX.js.map +1 -0
  55. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  56. package/dist/chunk-WPLAANH4.js.map +1 -0
  57. package/dist/config/index.d.ts +36 -35
  58. package/dist/config/index.js +4 -5
  59. package/dist/controls/index.d.ts +91 -7
  60. package/dist/controls/index.js +5 -4
  61. package/dist/dragdrop/index.d.ts +8 -6
  62. package/dist/dragdrop/index.js +3 -2
  63. package/dist/index.d.ts +50 -77
  64. package/dist/index.js +138 -254
  65. package/dist/index.js.map +1 -1
  66. package/dist/layers/index.d.ts +3 -4
  67. package/dist/layers/index.js +2 -1
  68. package/dist/loupe/index.d.ts +19 -16
  69. package/dist/loupe/index.js +5 -3
  70. package/dist/overview/index.d.ts +56 -0
  71. package/dist/overview/index.js +288 -0
  72. package/dist/overview/index.js.map +1 -0
  73. package/dist/passthrough/weasel-ui.d.ts +38 -38
  74. package/dist/passthrough/weasel-ui.js +2 -1
  75. package/dist/primitives/index.d.ts +19 -43
  76. package/dist/primitives/index.js +5 -3
  77. package/dist/state/index.d.ts +7 -7
  78. package/dist/state/index.js +4 -5
  79. package/dist/state/index.js.map +1 -1
  80. package/dist/styles.css +63 -1
  81. package/dist/undo/index.d.ts +3 -3
  82. package/package.json +13 -9
  83. package/src/annotations/index.ts +1 -0
  84. package/src/annotations/store.test.ts +26 -0
  85. package/src/annotations/store.ts +25 -6
  86. package/src/annotations/toolMap.test.ts +37 -2
  87. package/src/annotations/toolMap.ts +13 -0
  88. package/src/annotations/types.ts +27 -0
  89. package/src/canvas/AGENTS.md +8 -10
  90. package/src/canvas/CameraInput.test.tsx +136 -0
  91. package/src/canvas/CameraInput.tsx +311 -0
  92. package/src/canvas/CanvasStack.tsx +42 -58
  93. package/src/canvas/LinkedCursor.less +16 -0
  94. package/src/canvas/LinkedCursor.tsx +54 -0
  95. package/src/canvas/Stage.tsx +42 -37
  96. package/src/canvas/cameraView.test.ts +60 -0
  97. package/src/canvas/cameraView.ts +61 -0
  98. package/src/canvas/index.ts +11 -2
  99. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  100. package/src/config/builder.ts +3 -3
  101. package/src/config/entry.test.ts +3 -1
  102. package/src/config/fromConfigField.ts +1 -0
  103. package/src/config/index.ts +4 -1
  104. package/src/config/resolve.ts +11 -1
  105. package/src/config/sectionTree.test.ts +12 -6
  106. package/src/config/sectionTree.ts +15 -5
  107. package/src/config/types.ts +21 -0
  108. package/src/controls/ControlPanel.test.tsx +12 -2
  109. package/src/controls/ControlPanel.tsx +28 -9
  110. package/src/controls/inDialog.test.tsx +18 -0
  111. package/src/controls/inDialog.tsx +2 -10
  112. package/src/index.test.ts +1 -1
  113. package/src/index.ts +8 -4
  114. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  115. package/src/instrument/types.ts +10 -1
  116. package/src/lab/Lab.nebula.test.tsx +11 -2
  117. package/src/lab/Lab.test.tsx +3 -1
  118. package/src/lab/Lab.tsx +4 -4
  119. package/src/loupe/AGENTS.md +7 -6
  120. package/src/loupe/LoupeGestures.tsx +14 -9
  121. package/src/loupe/TrialLoupe.tsx +5 -5
  122. package/src/loupe/index.ts +1 -0
  123. package/src/loupe/loupeActions.ts +6 -2
  124. package/src/overview/AGENTS.md +38 -0
  125. package/src/overview/OverviewMarks.test.tsx +100 -0
  126. package/src/overview/OverviewMarks.tsx +93 -0
  127. package/src/overview/TrialOverview.less +53 -0
  128. package/src/overview/TrialOverview.stories.tsx +50 -0
  129. package/src/overview/TrialOverview.test.tsx +116 -0
  130. package/src/overview/TrialOverview.tsx +374 -0
  131. package/src/overview/index.ts +2 -0
  132. package/src/passthrough/weasel-ui.ts +7 -0
  133. package/src/specimen/Specimen.tsx +19 -2
  134. package/src/state/labRecords.test.ts +4 -1
  135. package/src/state/store.test.ts +4 -4
  136. package/src/state/useTrialState.test.tsx +1 -1
  137. package/src/styles.less +2 -0
  138. package/src/theme/Interstellar.stories.tsx +1 -5
  139. package/src/tools/labTool.ts +6 -2
  140. package/src/trial/Trial.annotations.test.tsx +16 -0
  141. package/src/trial/Trial.loupe.test.tsx +73 -1
  142. package/src/trial/Trial.stories.tsx +2 -0
  143. package/src/trial/Trial.tsx +17 -1
  144. package/src/trial/trialOps.test.ts +4 -4
  145. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  146. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  147. package/dist/chunk-2AYGEN57.js +0 -32
  148. package/dist/chunk-2AYGEN57.js.map +0 -1
  149. package/dist/chunk-AWH3SRPW.js.map +0 -1
  150. package/dist/chunk-BVL7Y574.js.map +0 -1
  151. package/dist/chunk-ERFMC6WU.js.map +0 -1
  152. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  153. package/dist/chunk-HFRE4QQV.js.map +0 -1
  154. package/dist/chunk-RS3HRQWU.js.map +0 -1
  155. package/dist/chunk-TT5C5S6R.js +0 -368
  156. package/dist/chunk-TT5C5S6R.js.map +0 -1
  157. package/dist/chunk-USO6TERP.js.map +0 -1
  158. package/dist/chunk-VQ47KWD5.js.map +0 -1
  159. package/dist/chunk-WUJXJC5O.js.map +0 -1
  160. package/dist/chunk-ZORVNOKX.js.map +0 -1
  161. package/src/canvas/usePanZoom.test.ts +0 -244
  162. package/src/canvas/usePanZoom.ts +0 -147
@@ -1,9 +1,9 @@
1
- export { au as UndoCapability } from '../_dts/frac-DTRq7Xpb.js';
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 '@weasel-js/core';
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.0",
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.0",
115
- "@weasel-js/theme": "1.6.0",
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.0",
121
- "@weasel-js/kernel3d": "1.6.0",
122
- "@weasel-js/loupe": "1.6.0",
123
- "@weasel-js/svg": "1.6.0",
124
- "@weasel-js/ui": "1.6.0",
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.0",
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",
@@ -28,6 +28,7 @@ export type {
28
28
  AnnotationsCapability,
29
29
  AnnotationTarget,
30
30
  AnnotationTargetInfo,
31
+ AnnotationToolId,
31
32
  CaptureOptions,
32
33
  CaptureResult,
33
34
  CaptureSource,
@@ -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
+ });
@@ -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 { ANNOTATION_TOOLS, annotationToolInfo } from './toolMap';
4
- import type { AnnotationKind } from './types';
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];
@@ -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>;
@@ -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, pan/zoom handlers, and per-layer canvas refs |
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
- | `usePanZoom.ts` | Pointer + wheel handlers that mutate `view` via `onViewChange` |
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, forwarded to usePanZoom
36
- maxZoom?: number; // default 32, forwarded to usePanZoom
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 `usePanZoom`, to the scheduler, and to every `render`.
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 wheel anchor in `usePanZoom`, and
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
+ });