@weasel-js/labkit 1.5.2 → 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 (217) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CMMo180l.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  3. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  4. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +83 -0
  5. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  6. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +404 -0
  7. package/dist/_dts/{Select.d-ZTiwzYyT.d.ts → Select.d-Cr8-zoup.d.ts} +105 -15
  8. package/dist/_dts/{usePanZoom-SSTZe410.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  9. package/dist/_dts/{index-DlKolEIZ.d.ts → index-CKyX0ME_.d.ts} +12 -13
  10. package/dist/_dts/{frac-B4hFJMiN.d.ts → types-B7yBTTF9.d.ts} +709 -651
  11. package/dist/_dts/{types-Db-3NNk1.d.ts → types-DTuViBvc.d.ts} +21 -2
  12. package/dist/_dts/{useTrialState-pnAE-UBD.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +41 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +6 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-2RVCB2PT.js +3 -0
  20. package/dist/chunk-2RVCB2PT.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/{chunk-CKUKF72R.js → chunk-4TT3PNPR.js} +68 -55
  24. package/dist/chunk-4TT3PNPR.js.map +1 -0
  25. package/dist/{chunk-KJCUERQF.js → chunk-6FLXL3I7.js} +9 -164
  26. package/dist/chunk-6FLXL3I7.js.map +1 -0
  27. package/dist/chunk-ASOK76ZH.js +431 -0
  28. package/dist/chunk-ASOK76ZH.js.map +1 -0
  29. package/dist/chunk-AWQG7FCC.js +19 -0
  30. package/dist/chunk-AWQG7FCC.js.map +1 -0
  31. package/dist/chunk-CGHF6RZV.js +300 -0
  32. package/dist/chunk-CGHF6RZV.js.map +1 -0
  33. package/dist/chunk-DWMSB4CA.js +228 -0
  34. package/dist/chunk-DWMSB4CA.js.map +1 -0
  35. package/dist/chunk-GHGO52SO.js +92 -0
  36. package/dist/chunk-GHGO52SO.js.map +1 -0
  37. package/dist/{chunk-LRI3MBGR.js → chunk-HKV7LRHY.js} +4 -4
  38. package/dist/{chunk-LRI3MBGR.js.map → chunk-HKV7LRHY.js.map} +1 -1
  39. package/dist/{chunk-EMXAIRUJ.js → chunk-IRTGUQ5O.js} +5 -4
  40. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/chunk-KHK5FGER.js +145 -0
  44. package/dist/chunk-KHK5FGER.js.map +1 -0
  45. package/dist/chunk-NEXR37YJ.js +144 -0
  46. package/dist/chunk-NEXR37YJ.js.map +1 -0
  47. package/dist/{chunk-A2ACAGW6.js → chunk-O6FFFOHS.js} +32 -7
  48. package/dist/chunk-O6FFFOHS.js.map +1 -0
  49. package/dist/chunk-QGMOA2YE.js +731 -0
  50. package/dist/chunk-QGMOA2YE.js.map +1 -0
  51. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  52. package/dist/chunk-S4NCI32V.js.map +1 -0
  53. package/dist/{chunk-753Y6HE6.js → chunk-SLAQPUI4.js} +4 -4
  54. package/dist/chunk-SLAQPUI4.js.map +1 -0
  55. package/dist/{chunk-REOBGYMC.js → chunk-TJ77A4A3.js} +3 -5
  56. package/dist/chunk-TJ77A4A3.js.map +1 -0
  57. package/dist/chunk-TYN6MLXX.js +13864 -0
  58. package/dist/chunk-TYN6MLXX.js.map +1 -0
  59. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  60. package/dist/chunk-WPLAANH4.js.map +1 -0
  61. package/dist/config/index.d.ts +51 -7
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +92 -5
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -6
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +108 -154
  68. package/dist/index.js +456 -600
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +5 -35
  71. package/dist/layers/index.js +3 -3
  72. package/dist/loupe/index.d.ts +21 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +288 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +434 -231
  78. package/dist/passthrough/weasel-ui.js +2 -1
  79. package/dist/primitives/index.d.ts +19 -54
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -7
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +360 -174
  85. package/dist/undo/index.d.ts +5 -4
  86. package/package.json +13 -13
  87. package/src/annotations/frac.test.ts +7 -0
  88. package/src/annotations/index.ts +1 -0
  89. package/src/annotations/paint.test.ts +21 -0
  90. package/src/annotations/paint.ts +12 -0
  91. package/src/annotations/store.test.ts +57 -0
  92. package/src/annotations/store.ts +25 -6
  93. package/src/annotations/toolMap.test.ts +37 -2
  94. package/src/annotations/toolMap.ts +13 -0
  95. package/src/annotations/types.ts +30 -2
  96. package/src/canvas/AGENTS.md +8 -10
  97. package/src/canvas/CameraInput.test.tsx +136 -0
  98. package/src/canvas/CameraInput.tsx +311 -0
  99. package/src/canvas/CanvasStack.tsx +42 -58
  100. package/src/canvas/LinkedCursor.less +16 -0
  101. package/src/canvas/LinkedCursor.tsx +54 -0
  102. package/src/canvas/Stage.test.tsx +18 -0
  103. package/src/canvas/Stage.tsx +42 -37
  104. package/src/canvas/cameraView.test.ts +60 -0
  105. package/src/canvas/cameraView.ts +61 -0
  106. package/src/canvas/index.ts +11 -2
  107. package/src/chrome/regions/SidebarRegion.less +197 -0
  108. package/src/chrome/regions/SidebarRegion.test.tsx +27 -0
  109. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  110. package/src/chrome/types.ts +5 -2
  111. package/src/config/autoConfig.ts +2 -2
  112. package/src/config/builder.ts +21 -2
  113. package/src/config/entry.test.ts +10 -0
  114. package/src/config/fromConfigField.ts +1 -0
  115. package/src/config/index.ts +5 -0
  116. package/src/config/resolve.ts +11 -1
  117. package/src/config/rules.ts +2 -0
  118. package/src/config/sectionTree.test.ts +152 -0
  119. package/src/config/sectionTree.ts +134 -0
  120. package/src/config/types.ts +21 -0
  121. package/src/controls/ControlPanel.less +4 -0
  122. package/src/controls/ControlPanel.stories.tsx +55 -2
  123. package/src/controls/ControlPanel.test.tsx +40 -3
  124. package/src/controls/ControlPanel.tsx +134 -336
  125. package/src/controls/inDialog.test.tsx +100 -0
  126. package/src/controls/inDialog.tsx +38 -0
  127. package/src/controls/index.ts +1 -0
  128. package/src/index.test.ts +1 -1
  129. package/src/index.ts +18 -29
  130. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  131. package/src/instrument/types.ts +15 -3
  132. package/src/lab/Lab.nebula.test.tsx +40 -0
  133. package/src/lab/Lab.test.tsx +3 -1
  134. package/src/lab/Lab.tsx +42 -19
  135. package/src/lab/LabBoundary.test.tsx +66 -0
  136. package/src/lab/LabBoundary.tsx +56 -0
  137. package/src/lab/LabHeader.tsx +4 -23
  138. package/src/lab/LabRoot.tsx +2 -3
  139. package/src/lab/LabShell.less +0 -1
  140. package/src/lab/LabShell.stories.tsx +15 -0
  141. package/src/lab/LabShell.test.tsx +18 -0
  142. package/src/lab/LabShell.tsx +14 -1
  143. package/src/lab/LabSwitcher.less +1 -1
  144. package/src/lab/LabSwitcher.test.tsx +41 -0
  145. package/src/lab/LabSwitcher.tsx +30 -8
  146. package/src/lab/index.ts +1 -0
  147. package/src/layers/AGENTS.md +18 -74
  148. package/src/layers/index.ts +4 -2
  149. package/src/loupe/AGENTS.md +7 -6
  150. package/src/loupe/LoupeGestures.tsx +14 -9
  151. package/src/loupe/TrialLoupe.tsx +5 -5
  152. package/src/loupe/index.ts +1 -0
  153. package/src/loupe/loupeActions.ts +6 -2
  154. package/src/overview/AGENTS.md +38 -0
  155. package/src/overview/OverviewMarks.test.tsx +100 -0
  156. package/src/overview/OverviewMarks.tsx +93 -0
  157. package/src/overview/TrialOverview.less +53 -0
  158. package/src/overview/TrialOverview.stories.tsx +50 -0
  159. package/src/overview/TrialOverview.test.tsx +116 -0
  160. package/src/overview/TrialOverview.tsx +374 -0
  161. package/src/overview/index.ts +2 -0
  162. package/src/passthrough/weasel-ui.ts +32 -10
  163. package/src/primitives/index.ts +0 -2
  164. package/src/specimen/Specimen.tsx +59 -56
  165. package/src/state/labRecords.test.ts +4 -1
  166. package/src/state/store.test.ts +4 -4
  167. package/src/state/types.ts +2 -1
  168. package/src/state/useTrialState.test.tsx +1 -1
  169. package/src/styles.less +6 -2
  170. package/src/theme/Interstellar.stories.tsx +8 -13
  171. package/src/theme/base.less +3 -20
  172. package/src/theme/interstellar.test.ts +6 -3
  173. package/src/theme/interstellar.theme.json +0 -56
  174. package/src/theme/interstellar.ts +2 -2
  175. package/src/tools/labTool.ts +6 -2
  176. package/src/trial/Trial.annotations.test.tsx +16 -0
  177. package/src/trial/Trial.canvas.test.tsx +1 -1
  178. package/src/trial/Trial.loupe.test.tsx +73 -1
  179. package/src/trial/Trial.stories.tsx +2 -0
  180. package/src/trial/Trial.tsx +47 -9
  181. package/src/trial/trialOps.test.ts +4 -4
  182. package/dist/_dts/index-8LiKe3A-.d.ts +0 -410
  183. package/dist/chunk-2AYGEN57.js +0 -32
  184. package/dist/chunk-2AYGEN57.js.map +0 -1
  185. package/dist/chunk-6BUQ77ZV.js +0 -18
  186. package/dist/chunk-6BUQ77ZV.js.map +0 -1
  187. package/dist/chunk-753Y6HE6.js.map +0 -1
  188. package/dist/chunk-A2ACAGW6.js.map +0 -1
  189. package/dist/chunk-CKUKF72R.js.map +0 -1
  190. package/dist/chunk-D35PFTS7.js +0 -170
  191. package/dist/chunk-D35PFTS7.js.map +0 -1
  192. package/dist/chunk-EMXAIRUJ.js.map +0 -1
  193. package/dist/chunk-ERFMC6WU.js.map +0 -1
  194. package/dist/chunk-FKLT3ITC.js +0 -590
  195. package/dist/chunk-FKLT3ITC.js.map +0 -1
  196. package/dist/chunk-KJCUERQF.js.map +0 -1
  197. package/dist/chunk-LASCBWW7.js +0 -13456
  198. package/dist/chunk-LASCBWW7.js.map +0 -1
  199. package/dist/chunk-REOBGYMC.js.map +0 -1
  200. package/dist/chunk-RS3HRQWU.js.map +0 -1
  201. package/dist/chunk-TT5C5S6R.js +0 -368
  202. package/dist/chunk-TT5C5S6R.js.map +0 -1
  203. package/dist/ui/layers/index.d.ts +0 -66
  204. package/dist/ui/layers/index.js +0 -3
  205. package/dist/ui/layers/index.js.map +0 -1
  206. package/src/canvas/usePanZoom.test.ts +0 -244
  207. package/src/canvas/usePanZoom.ts +0 -147
  208. package/src/lab/useSystemMode.ts +0 -30
  209. package/src/layers/LayerList.less +0 -93
  210. package/src/layers/LayerList.stories.tsx +0 -66
  211. package/src/layers/LayerList.test.tsx +0 -232
  212. package/src/layers/LayerList.tsx +0 -268
  213. package/src/primitives/Sidebar.less +0 -112
  214. package/src/primitives/Sidebar.stories.tsx +0 -38
  215. package/src/primitives/Sidebar.test.tsx +0 -74
  216. package/src/primitives/Sidebar.tsx +0 -35
  217. package/src/ui/layers/index.ts +0 -5
@@ -1,8 +1,9 @@
1
- export { au as UndoCapability } from '../_dts/frac-B4hFJMiN.js';
2
- import 'react';
3
- import '../_dts/types-Db-3NNk1.js';
1
+ export { az as UndoCapability } from '../_dts/types-B7yBTTF9.js';
4
2
  import '@weasel-js/core';
5
- import '../_dts/PrefsForm.d-DPy9I8It.js';
3
+ import 'react';
4
+ import '../_dts/PrefsRow.d-DFpPIeIC.js';
5
+ import '@weasel-js/theme';
6
+ import '../_dts/types-DTuViBvc.js';
6
7
  import '../_dts/types-DJ79Tg5J.js';
7
8
 
8
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.5.2",
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"
@@ -82,10 +86,6 @@
82
86
  "types": "./dist/passthrough/weasel-canvas.d.ts",
83
87
  "import": "./dist/passthrough/weasel-canvas.js"
84
88
  },
85
- "./ui/layers": {
86
- "types": "./dist/ui/layers/index.d.ts",
87
- "import": "./dist/ui/layers/index.js"
88
- },
89
89
  "./styles.css": "./dist/styles.css",
90
90
  "./loupe": {
91
91
  "types": "./dist/loupe/index.d.ts",
@@ -115,17 +115,17 @@
115
115
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
116
116
  },
117
117
  "peerDependencies": {
118
- "@weasel-js/core": "1.5.2",
119
- "@weasel-js/theme": "1.5.2",
118
+ "@weasel-js/core": "1.6.1",
119
+ "@weasel-js/theme": "1.6.1",
120
120
  "react": "^19.0.0",
121
121
  "react-dom": "^19.0.0"
122
122
  },
123
123
  "dependencies": {
124
- "@weasel-js/geom": "1.5.2",
125
- "@weasel-js/kernel3d": "1.5.2",
126
- "@weasel-js/loupe": "1.5.2",
127
- "@weasel-js/svg": "1.5.2",
128
- "@weasel-js/ui": "1.5.2",
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",
129
129
  "earcut": "2.2.4",
130
130
  "polygon-clipping": "^0.15.7",
131
131
  "react-aria-components": "^1.5.0",
@@ -136,7 +136,7 @@
136
136
  "@biomejs/biome": "^2.4.12",
137
137
  "@rollup/plugin-alias": "^6.0.0",
138
138
  "@testing-library/jest-dom": "^6.6.0",
139
- "@weasel-js/modes": "1.5.2",
139
+ "@weasel-js/modes": "1.6.1",
140
140
  "less": "^4.2.0",
141
141
  "rollup-plugin-dts": "^6.4.1",
142
142
  "tsx": "^4.19.0",
@@ -51,6 +51,13 @@ describe('fraction hit geometry', () => {
51
51
  expect(fracContains(box, { x: 0.18, y: 0.3 }, 0.05)).toBe(true);
52
52
  });
53
53
 
54
+ it('reaches a zero-size box through the tolerance alone', () => {
55
+ const dot: FracRect = { x: 0.25, y: 0.5, w: 0, h: 0 };
56
+ expect(fracContains(dot, { x: 0.25, y: 0.5 })).toBe(true);
57
+ expect(fracContains(dot, { x: 0.26, y: 0.51 })).toBe(false);
58
+ expect(fracContains(dot, { x: 0.26, y: 0.51 }, 0.02)).toBe(true);
59
+ });
60
+
54
61
  it('encloses only a box that lies wholly inside', () => {
55
62
  expect(fracEncloses({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
56
63
  // Overlapping is not containing: a marquee takes what it encloses.
@@ -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,
@@ -99,6 +99,20 @@ describe('markCommands', () => {
99
99
  it('draws an untitled text mark as nothing rather than an empty run', () => {
100
100
  expect(markCommands(mark('text'), CONTENT)).toEqual([]);
101
101
  });
102
+
103
+ it('draws a point as a small unfilled ring centered on its one stored point', () => {
104
+ const cmds = markCommands(mark('point', { points: [{ x: 0.25, y: 0.5 }] }), CONTENT);
105
+ expect(cmds).toHaveLength(1);
106
+ expect((cmds[0] as { fill?: unknown }).fill).toBeUndefined();
107
+ const pts = anchors(cmds);
108
+ const xs = pts.filter((_, i) => i % 2 === 0);
109
+ const ys = pts.filter((_, i) => i % 2 === 1);
110
+ // Centered on (50, 50) in world, radius 4.
111
+ expect(Math.min(...xs)).toBeCloseTo(46, 4);
112
+ expect(Math.max(...xs)).toBeCloseTo(54, 4);
113
+ expect(Math.min(...ys)).toBeCloseTo(46, 4);
114
+ expect(Math.max(...ys)).toBeCloseTo(54, 4);
115
+ });
102
116
  });
103
117
 
104
118
  describe('markCommands styling', () => {
@@ -117,6 +131,13 @@ describe('markCommands styling', () => {
117
131
  expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
118
132
  });
119
133
 
134
+ it('dashes a stale point ring like any other mark', () => {
135
+ const [ring] = markCommands(mark('point', { points: [{ x: 0.25, y: 0.5 }] }), CONTENT, {
136
+ stale: true,
137
+ });
138
+ expect((ring as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
139
+ });
140
+
120
141
  it("colors a text mark's glyphs too, not only the outlines", () => {
121
142
  const [cmd] = markCommands(mark('text', { title: 'x' }), CONTENT, { color: '#30a46c' });
122
143
  const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
@@ -17,6 +17,7 @@ const MARK_COLOR = '#e5484d';
17
17
  * marker geometry can be resolved here, which needs a resolved width. */
18
18
  const MARK_WIDTH = 2;
19
19
  const TEXT_SIZE = 14;
20
+ const POINT_RADIUS = 4;
20
21
 
21
22
  /** The subset of a mark's scene node this needs: where it is, and what it is. */
22
23
  export interface PaintableMark {
@@ -108,6 +109,17 @@ export function markCommands(
108
109
  }
109
110
  case 'stroke':
110
111
  return [{ kind: 'path', path: polyline(vertices(m, content)), stroke }];
112
+ case 'point': {
113
+ const stored = m.data.points?.[0];
114
+ const c = stored ? toWorld(stored, content) : { x: m.pose.x, y: m.pose.y };
115
+ const ring = {
116
+ x: c.x - POINT_RADIUS,
117
+ y: c.y - POINT_RADIUS,
118
+ width: 2 * POINT_RADIUS,
119
+ height: 2 * POINT_RADIUS,
120
+ };
121
+ return [{ kind: 'path', path: ellipsePath(ring), stroke }];
122
+ }
111
123
  case 'text': {
112
124
  const text = m.data.title;
113
125
  if (!text) return [];
@@ -245,6 +245,37 @@ describe('the annotation store', () => {
245
245
  expect(revived.query()).toHaveLength(1);
246
246
  });
247
247
 
248
+ it('round-trips a point mark with its one stored point and zero-size bounds', () => {
249
+ const store = makeStore();
250
+ const id = store.add({
251
+ target: 'naive',
252
+ kind: 'point',
253
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
254
+ points: [{ x: 0.25, y: 0.5 }],
255
+ });
256
+ expect(store.get(id)).toMatchObject({
257
+ kind: 'point',
258
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
259
+ points: [{ x: 0.25, y: 0.5 }],
260
+ });
261
+
262
+ const revived = annotationsFromJSON(JSON.parse(JSON.stringify(store.toJSON())), () => TARGETS);
263
+ expect(revived.get(id)).toEqual(store.get(id));
264
+ });
265
+
266
+ it('hit-tests a point mark through the tolerance, since its bounds have no area', () => {
267
+ const store = makeStore();
268
+ const id = store.add({
269
+ target: 'naive',
270
+ kind: 'point',
271
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
272
+ points: [{ x: 0.25, y: 0.5 }],
273
+ });
274
+ expect(store.hitTest('naive', { x: 0.26, y: 0.51 })).toHaveLength(0);
275
+ expect(store.hitTest('naive', { x: 0.26, y: 0.51 }, 0.02).map((a) => a.id)).toEqual([id]);
276
+ expect(store.hitTest('naive', { x: 0.3, y: 0.51 }, 0.02)).toHaveLength(0);
277
+ });
278
+
248
279
  it('reads a meaning passed as a thunk at each capture', async () => {
249
280
  let color = '#111111';
250
281
  const store = annotationsFromJSON(null, () => TARGETS, {
@@ -263,3 +294,29 @@ describe('the annotation store', () => {
263
294
  expect(svg).toContain('#222222');
264
295
  });
265
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. */
@@ -21,8 +22,9 @@ export interface FracRect {
21
22
  }
22
23
 
23
24
  /** What shape a mark is. The kinds map onto weasel's own tools; an arrow is a
24
- * line carrying an end marker, not a separate geometry. */
25
- export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text';
25
+ * line carrying an end marker, not a separate geometry. A point holds one
26
+ * entry in `points` and zero-size bounds at that position. */
27
+ export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text' | 'point';
26
28
 
27
29
  /** One selectable status in an instrument's meaning tier. */
28
30
  export interface AnnotationStatus {
@@ -159,10 +161,25 @@ export interface AnnotationStorage {
159
161
 
160
162
  /** Declares that an instrument accepts marks: which regions take them,
161
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
+
162
175
  export interface AnnotationsCapability<TS = unknown, TC = unknown> {
163
176
  /** `trial` is which trial is asking: a declaration made once per instrument
164
177
  * is called once per trial, and its targets are that trial's own. */
165
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[];
166
183
  meaning?: AnnotationMeaning;
167
184
  /** An instrument replacing this one under a live trial must pass the same
168
185
  * object: the trial keeps the marks it loaded from the first. */
@@ -182,6 +199,12 @@ export interface SerializedAnnotations {
182
199
  scenes: Record<string, unknown>;
183
200
  }
184
201
 
202
+ /** One mark, as `AnnotationsApi.paintedMarks` hands it over. */
203
+ export interface PaintedMark {
204
+ mark: PaintableMark;
205
+ style: MarkStyle;
206
+ }
207
+
185
208
  /** Everything a host can ask or tell labkit about the marks on its targets. */
186
209
  export interface AnnotationsApi {
187
210
  /** The scene a target's marks live in, created on first ask. One per target
@@ -221,6 +244,11 @@ export interface AnnotationsApi {
221
244
  undo(): boolean;
222
245
  redo(): boolean;
223
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
+
224
252
  /** Export a target's picture with its marks on it. Rejects on an id the
225
253
  * instrument does not declare. */
226
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
+ });