@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
@@ -19,12 +19,21 @@ const backdrop = (container: HTMLElement) =>
19
19
 
20
20
  describe('<Lab nebula>', () => {
21
21
  it('draws one blob per literal color', () => {
22
- const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={['#ff0000', '#00ff00']} />);
22
+ const { container } = render(
23
+ <Lab
24
+ instruments={[bare]}
25
+ defaultInstrument="Bare"
26
+ mode="dark"
27
+ nebula={['#ff0000', '#00ff00']}
28
+ />,
29
+ );
23
30
  expect(backdrop(container).match(/radial-gradient/g)).toHaveLength(3);
24
31
  });
25
32
 
26
33
  it('resolves a theme ramp against the lab’s theme', () => {
27
- const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />);
34
+ const { container } = render(
35
+ <Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />,
36
+ );
28
37
  const sky = resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-swatch-sky'];
29
38
  expect(backdrop(container)).toContain(sky);
30
39
  });
@@ -165,7 +165,9 @@ describe('<Lab>', () => {
165
165
  storageKey: 's',
166
166
  });
167
167
  await waitFor(() => expect(labRef?.trials[0]).toBeDefined());
168
- expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
168
+ expect((labRef?.trials[0]?.state as { seen: Set<string> } | undefined)?.seen).toEqual(
169
+ new Set(['a']),
170
+ );
169
171
  });
170
172
  });
171
173
 
package/src/lab/Lab.tsx CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from 'react';
13
13
  import { useStore } from 'zustand/react';
14
14
  import { AnnotationPreloadContext } from '../annotations/preload';
15
- import { ANNOTATION_TOOLS } from '../annotations/toolMap';
15
+ import { labAnnotationTools } from '../annotations/toolMap';
16
16
  import {
17
17
  LabAsideRegion,
18
18
  LabFooterRegion,
@@ -478,10 +478,10 @@ function LabRuntime({
478
478
  // of them silently losing.
479
479
  // Declaring `annotations` on any instrument puts the drawing tools here, in
480
480
  // the lab's rail: one tool is armed across every trial.
481
- const annotates = instruments.some((i) => i.annotations != null);
481
+ const annotationTools = useMemo(() => labAnnotationTools(instruments), [instruments]);
482
482
  const labChromeAll = useMemo(
483
- () => labContributions([...(annotates ? ANNOTATION_TOOLS : []), ...(tools ?? [])], labChrome),
484
- [annotates, tools, labChrome],
483
+ () => labContributions([...annotationTools, ...(tools ?? [])], labChrome),
484
+ [annotationTools, tools, labChrome],
485
485
  );
486
486
  const hasFooterChrome = labChromeAll.some((c) => c.region === 'footer');
487
487
  const hasPaneChrome = labChromeAll.some((c) => c.region === 'sidebar' || c.region === 'aside');
@@ -14,7 +14,7 @@ This directory binds that model to a labkit trial and draws it.
14
14
  | `types.ts` | `LoupeCapability`, and `resolveLoupe` filling in every default |
15
15
  | `useLoupe.ts` | The model over a host element, and pointer aiming |
16
16
  | `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
17
- | `LoupeGestures.tsx` | Registers those and mounts the dispatcher on the host |
17
+ | `LoupeGestures.tsx` | Registers those, and mounts a dispatcher on the host when no camera has one there |
18
18
  | `TrialLoupe.tsx` | Picks the painter and mounts the lens |
19
19
  | `LoupeBubble.tsx` | The circular clip, positioned on the aim |
20
20
  | `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
@@ -46,11 +46,12 @@ StrictMode mounts / unmounts / mounts every effect — so disposing in the
46
46
  cleanup leaves a magnifier that draws but silently ignores every aim. It owns
47
47
  no resources; unmounting only reports the lens gone.
48
48
 
49
- **The wheel is taken from pan/zoom by declining it, not by capturing it.**
50
- `loupe.magnify`'s `enabled` returns a disabled reason while the lens is down, so
51
- the dispatcher leaves the event unhandled and `usePanZoom` — a React handler on
52
- the same element — sees it as usual. While the lens is up the action fires, the
53
- dispatcher stops propagation, and React's root listener never runs.
49
+ **The wheel is taken from the camera by rank, and handed back by declining.**
50
+ In a trial the loupe's actions join the camera's dispatcher (`CameraInput`).
51
+ `loupe.magnify` sits in the hotkey tier, so while the lens is up it outranks
52
+ the camera's wheel zoom; while it is down its `enabled` returns a disabled
53
+ reason and the dispatcher falls through to the zoom. Outside a camera — the DOM
54
+ `.lk-trial__loupe-host` — `<LoupeGestures>` mounts a dispatcher of its own.
54
55
 
55
56
  **Aiming is a plain listener because a hover is not a gesture.**
56
57
  `GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Mounts the gesture dispatcher on a loupe's host and registers the loupe's
3
- * actions on it, so the peek key and the wheel route the way every other
4
- * weasel binding does.
2
+ * Registers the loupe's actions, and mounts the gesture dispatcher on the
3
+ * loupe's host when no camera already has one there, so the peek key and the
4
+ * wheel route the way every other weasel binding does.
5
5
  *
6
6
  * Three of the dispatcher's four element channels are off. Only `wheel` is
7
7
  * wanted here; `contextMenu` suppresses the native menu unconditionally and
@@ -14,7 +14,8 @@ import {
14
14
  useActionsRegistry,
15
15
  useGestureDispatcher,
16
16
  } from '@weasel-js/core';
17
- import { type RefObject, useEffect, useMemo } from 'react';
17
+ import { type RefObject, useContext, useEffect, useMemo } from 'react';
18
+ import { CameraContext } from '../canvas/CameraInput';
18
19
  import { createLoupeActions, type LoupeInputApi } from './loupeActions';
19
20
 
20
21
  /** Props for `<LoupeGestures>`. */
@@ -35,7 +36,8 @@ function LoupeDispatch({
35
36
  input,
36
37
  peekKey,
37
38
  registry,
38
- }: LoupeGesturesProps & { registry: ActionsRegistry }) {
39
+ ownDispatcher,
40
+ }: LoupeGesturesProps & { registry: ActionsRegistry; ownDispatcher: boolean }) {
39
41
  const actions = useMemo(() => createLoupeActions(input, peekKey), [input, peekKey]);
40
42
 
41
43
  useEffect(() => {
@@ -50,18 +52,21 @@ function LoupeDispatch({
50
52
  actions: registry,
51
53
  toolsById: NO_TOOLS,
52
54
  channels: CHANNELS,
55
+ enabled: ownDispatcher,
53
56
  });
54
57
 
55
58
  return null;
56
59
  }
57
60
 
58
61
  /**
59
- * The loupe's input, routed. Render it inside a `<WeaselProvider isolate>` —
60
- * with no registry in scope it renders nothing rather than registering
61
- * actions that could never fire.
62
+ * The loupe's input, routed. Inside a camera (`<CanvasStack>`, `<Stage>`) its
63
+ * actions join the camera's dispatcher, which already listens on the host;
64
+ * elsewhere it mounts a dispatcher of its own. With no registry in scope it
65
+ * renders nothing rather than registering actions that could never fire.
62
66
  */
63
67
  export function LoupeGestures(props: LoupeGesturesProps) {
64
68
  const registry = useActionsRegistry();
69
+ const camera = useContext(CameraContext);
65
70
  if (!registry) return null;
66
- return <LoupeDispatch {...props} registry={registry} />;
71
+ return <LoupeDispatch {...props} registry={registry} ownDispatcher={camera === null} />;
67
72
  }
@@ -1,5 +1,5 @@
1
- import { WeaselProvider } from '@weasel-js/core';
2
1
  import { type RefObject, useContext, useMemo } from 'react';
2
+ import { CameraScope } from '../canvas/CameraInput';
3
3
  import { CanvasStackContext } from '../canvas/CanvasStackContext';
4
4
  import type { WorldSpec } from '../canvas/worldSpec';
5
5
  import type { ViewTransform } from '../instrument/types';
@@ -63,12 +63,12 @@ export function TrialLoupe({
63
63
 
64
64
  // The gestures mount outside the visibility gate: hold-to-peek is what
65
65
  // raises a lens that is down, so its binding has to be live while it is.
66
- // One isolated scope per loupe, for the reason `<AnnotationOverlay>` gives
67
- // — an actions registry holds one dispatcher.
66
+ // In a trial they join the trial's scope, and with it the camera's
67
+ // dispatcher; outside one, `<CameraScope>` gives them an isolated scope.
68
68
  const gestures = (
69
- <WeaselProvider isolate>
69
+ <CameraScope>
70
70
  <LoupeGestures hostRef={host} input={loupe.input} peekKey={capability.peekKey ?? null} />
71
- </WeaselProvider>
71
+ </CameraScope>
72
72
  );
73
73
 
74
74
  if (!loupe.visible) return gestures;
@@ -1,3 +1,4 @@
1
+ export type { LoupeMode, LoupePoint } from '@weasel-js/loupe';
1
2
  export type { CanvasLoupeProps } from './CanvasLoupe';
2
3
  export { CanvasLoupe } from './CanvasLoupe';
3
4
  export type { LensCamera, SourceRect } from './canvasLens';
@@ -21,8 +21,8 @@ export const WHEEL_RATE = 0.002;
21
21
  * registration. */
22
22
  export interface LoupeInputApi {
23
23
  /** Whether the lens is up. The wheel belongs to the loupe only while it is;
24
- * otherwise `loupe.magnify` declines and the dispatcher leaves the event
25
- * unhandled, which is what hands it back to the lab's own pan/zoom. */
24
+ * otherwise `loupe.magnify` declines and the dispatcher falls through to
25
+ * the trial camera's zoom. */
26
26
  shown: () => boolean;
27
27
  /** Raise or drop the hold-to-peek flag. */
28
28
  setPeeking: (on: boolean) => void;
@@ -43,6 +43,10 @@ export function createLoupeActions(
43
43
  id: LOUPE_MAGNIFY_ID,
44
44
  label: 'Magnify (loupe)',
45
45
  group: 'loupe',
46
+ // Hotkey scope, so while the lens is up it outranks the trial camera's
47
+ // wheel zoom on the same dispatcher; while it is down `enabled` declines
48
+ // and the zoom runs.
49
+ scope: 'hotkey',
46
50
  // Every modifier optional: the lens claims the wheel whenever it is up,
47
51
  // which is what the hand-rolled capture-phase listener did. A bare spec
48
52
  // would forbid modifiers and hand Cmd+wheel back to the lab mid-peek.
@@ -0,0 +1,38 @@
1
+ # Overview — Agent Guide
2
+
3
+ `src/overview/` is `@weasel-js/labkit/overview`: `<TrialOverview>`, a trial's
4
+ whole content in a floating panel. Its own bundle entry; nothing in the main
5
+ bundle imports it.
6
+
7
+ | File | Role |
8
+ |---|---|
9
+ | `TrialOverview.tsx` | The panel: content, chrome canvas, and its own dispatcher |
10
+ | `OverviewMarks.tsx` | Every annotation target's marks, read-only, as SVG |
11
+
12
+ ## How it reads the trial
13
+
14
+ Only through `CameraContext`, which `<CanvasStack>` and `<Stage>` set: the
15
+ camera as a weasel `ViewApi` over frame-local coordinates, the frame, the
16
+ camera's element, a stage's content rect. A canvas instrument's layers come
17
+ from `CanvasStackContext`, marks from `AnnotationsContext`. So it works
18
+ anywhere inside a camera — `stage.overlay`, or a canvas instrument's `render`.
19
+
20
+ Input is core's `minimap.center` / `minimap.pan` on a dispatcher of its own,
21
+ with the camera as `rootView`; the pointer store is the trial's, shared.
22
+
23
+ ## Traps
24
+
25
+ **It sits inside the stage's element.** Every event that bubbles out of the box
26
+ reaches the stage's dispatcher, the loupe's aim and the stage's pointer
27
+ publisher. The box stops them; drop that and a drag in the overview also pans
28
+ the stage, and the pointer flips between `'overview'` and `'stage'` each move.
29
+
30
+ **Marks are placed by measuring.** A target's rect on the overview is its
31
+ element's rect against the camera's element, through the camera — never the
32
+ target's content size, which is the mark's world and not where it is drawn.
33
+ jsdom measures zero; a test mocks `getBoundingClientRect`, and the picture is a
34
+ screenshot.
35
+
36
+ **Never render the instrument's own `render` in it.** Its effects — a pane
37
+ ref, an annotation target — would run twice. `render` is a separate, lighter
38
+ copy the instrument supplies.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The overview draws every target's marks where that target sits on the stage,
3
+ * and redraws when one is added. jsdom lays nothing out, so the target's rect
4
+ * and the stage's are mocked; the stage's camera is the identity there.
5
+ */
6
+ import { act, render } from '@testing-library/react';
7
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
8
+ import { useAnnotations } from '../annotations/AnnotationsContext';
9
+ import type { AnnotationsApi } from '../annotations/types';
10
+ import { defineInstrument } from '../instrument/defineInstrument';
11
+ import { Lab } from '../lab/Lab';
12
+ import { TrialOverview } from './TrialOverview';
13
+
14
+ beforeAll(() => {
15
+ HTMLCanvasElement.prototype.getContext = vi.fn(
16
+ () => null,
17
+ ) as unknown as HTMLCanvasElement['getContext'];
18
+ });
19
+
20
+ const rect = (left: number, top: number, width: number, height: number) =>
21
+ ({
22
+ left,
23
+ top,
24
+ width,
25
+ height,
26
+ right: left + width,
27
+ bottom: top + height,
28
+ x: left,
29
+ y: top,
30
+ }) as DOMRect;
31
+
32
+ let restore: (() => void) | null = null;
33
+ afterEach(() => restore?.());
34
+
35
+ /** Stage 400×300, the pane at stage (50, 20) and 200×100. */
36
+ function mount() {
37
+ const pane = { current: null as HTMLDivElement | null };
38
+ let api: AnnotationsApi | null = null;
39
+ function Body() {
40
+ api = useAnnotations();
41
+ return (
42
+ <div
43
+ ref={(el) => {
44
+ pane.current = el;
45
+ }}
46
+ data-testid="pane"
47
+ />
48
+ );
49
+ }
50
+ const marked = defineInstrument<Record<string, never>, Record<string, never>>({
51
+ name: 'Marked',
52
+ defaultConfig: () => ({}),
53
+ initialState: () => ({}),
54
+ render: () => <Body />,
55
+ stage: {
56
+ size: { width: 400, height: 300 },
57
+ overlay: () => <TrialOverview width={200} height={150} render={() => <div />} />,
58
+ },
59
+ annotations: {
60
+ targets: () => [{ id: 'pane', ref: pane, content: { w: 200, h: 100 } }],
61
+ },
62
+ });
63
+ const original = HTMLElement.prototype.getBoundingClientRect;
64
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
65
+ if (this === pane.current) return rect(50, 20, 200, 100);
66
+ return rect(0, 0, 0, 0);
67
+ };
68
+ restore = () => {
69
+ HTMLElement.prototype.getBoundingClientRect = original;
70
+ };
71
+ const r = render(<Lab instruments={[marked]} defaultInstrument="Marked" />);
72
+ return { ...r, api: () => api as AnnotationsApi };
73
+ }
74
+
75
+ const px = (el: HTMLElement, name: string) => Number.parseFloat(el.style.getPropertyValue(name));
76
+
77
+ describe('marks on the overview', () => {
78
+ it('draws nothing for a target with no marks', () => {
79
+ const h = mount();
80
+ expect(h.container.querySelector('.lk-overview__marks')).toBeNull();
81
+ });
82
+
83
+ it("places a target's marks at the target's rect, through the fit", () => {
84
+ const h = mount();
85
+ act(() => {
86
+ h.api().add({ target: 'pane', kind: 'rect', frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } });
87
+ });
88
+ const marks = h.container.querySelector<HTMLElement>('.lk-overview__marks[data-target="pane"]');
89
+ expect(marks).not.toBeNull();
90
+ if (!marks) return;
91
+ // Content 400×300 into 200×150, 6px in: scale 0.46, camera at
92
+ // (200 - 100 / 0.46, 150 - 75 / 0.46).
93
+ const s = 0.46;
94
+ expect(px(marks, '--lk-overview-mx')).toBeCloseTo((50 - (200 - 100 / s)) * s);
95
+ expect(px(marks, '--lk-overview-my')).toBeCloseTo((20 - (150 - 75 / s)) * s);
96
+ expect(px(marks, '--lk-overview-mw')).toBeCloseTo(200 * s);
97
+ expect(px(marks, '--lk-overview-mh')).toBeCloseTo(100 * s);
98
+ expect(marks.querySelector('svg path')).not.toBeNull();
99
+ });
100
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Every annotation target's marks, drawn read-only on the overview through the
3
+ * export's own vector path: `markSvgNodes`, then `serializeSvg`.
4
+ */
5
+ import type { View } from '@weasel-js/core';
6
+ import { serializeSvg } from '@weasel-js/svg';
7
+ import { type CSSProperties, type RefObject, useEffect, useReducer } from 'react';
8
+ import { useAnnotationsOptional } from '../annotations/AnnotationsContext';
9
+ import { markSvgNodes } from '../annotations/svgNodes';
10
+ import type { AnnotationsApi } from '../annotations/types';
11
+ import type { CameraContextValue } from '../canvas/CameraInput';
12
+
13
+ /** A target's box, in the camera's frame-local units. */
14
+ interface LocalRect {
15
+ x: number;
16
+ y: number;
17
+ w: number;
18
+ h: number;
19
+ }
20
+
21
+ /** Where `el` sits in the camera's frame-local units: its screen rect,
22
+ * measured against the camera's element, through the camera. */
23
+ function localRectOf(el: HTMLElement, camera: CameraContextValue): LocalRect | null {
24
+ const host = camera.element();
25
+ if (!host) return null;
26
+ const r = el.getBoundingClientRect();
27
+ const h = host.getBoundingClientRect();
28
+ const v = camera.view.get();
29
+ return {
30
+ x: (r.left - h.left) / v.scale.x + v.x,
31
+ y: (r.top - h.top) / v.scale.y + v.y,
32
+ w: r.width / v.scale.x,
33
+ h: r.height / v.scale.y,
34
+ };
35
+ }
36
+
37
+ /** The element a target's marks sit on. The capability's targets carry their
38
+ * `ref`; the store's type names only the half it reads. */
39
+ function elementOf(target: object): HTMLElement | null {
40
+ return (target as { ref?: RefObject<HTMLElement | null> }).ref?.current ?? null;
41
+ }
42
+
43
+ function markupOf(api: AnnotationsApi, target: string, content: { w: number; h: number }): string {
44
+ const painted = api.paintedMarks(target);
45
+ if (painted.length === 0) return '';
46
+ const nodes = painted.flatMap(({ mark, style }) => markSvgNodes(mark, content, style));
47
+ return serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: content.w, height: content.h } });
48
+ }
49
+
50
+ /** Props for `<OverviewMarks>`. */
51
+ export interface OverviewMarksProps {
52
+ fit: View;
53
+ camera: CameraContextValue;
54
+ }
55
+
56
+ /**
57
+ * The marks of every target in the annotations store in scope, placed where
58
+ * each target's element sits on the stage. Draws nothing outside an annotating
59
+ * trial, and takes no input.
60
+ */
61
+ export function OverviewMarks({ fit, camera }: OverviewMarksProps) {
62
+ const api = useAnnotationsOptional();
63
+ const [, redraw] = useReducer((n: number) => n + 1, 0);
64
+ useEffect(() => api?.subscribe(redraw), [api]);
65
+ if (!api) return null;
66
+ const s = fit.scale.x;
67
+ return (
68
+ <>
69
+ {api.targets().map((t) => {
70
+ const el = elementOf(t);
71
+ const box = el ? localRectOf(el, camera) : null;
72
+ const markup = box ? markupOf(api, t.id, t.content) : '';
73
+ if (!box || !markup) return null;
74
+ const place = {
75
+ ['--lk-overview-mx' as string]: `${(box.x - fit.x) * s}px`,
76
+ ['--lk-overview-my' as string]: `${(box.y - fit.y) * s}px`,
77
+ ['--lk-overview-mw' as string]: `${box.w * s}px`,
78
+ ['--lk-overview-mh' as string]: `${box.h * s}px`,
79
+ } as CSSProperties;
80
+ return (
81
+ <div
82
+ key={t.id}
83
+ className="lk-overview__marks"
84
+ style={place}
85
+ data-target={t.id}
86
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: the export's SVG serializer is the one vector path marks have; re-deriving React nodes would be a second.
87
+ dangerouslySetInnerHTML={{ __html: markup }}
88
+ />
89
+ );
90
+ })}
91
+ </>
92
+ );
93
+ }
@@ -0,0 +1,53 @@
1
+ .lk-overview__title {
2
+ padding: 2px 6px 4px;
3
+ font-size: var(--wzl-font-size-sm);
4
+ color: var(--wzl-fg-muted);
5
+ text-transform: uppercase;
6
+ letter-spacing: 0.05em;
7
+ cursor: grab;
8
+ }
9
+
10
+ .lk-overview__box {
11
+ position: relative;
12
+ width: var(--lk-overview-w);
13
+ height: var(--lk-overview-h);
14
+ overflow: hidden;
15
+ background: var(--wzl-surface-sunken);
16
+ touch-action: none;
17
+ cursor: crosshair;
18
+ }
19
+
20
+ .lk-overview__content {
21
+ position: absolute;
22
+ top: 0;
23
+ left: 0;
24
+ width: var(--lk-overview-cw);
25
+ height: var(--lk-overview-ch);
26
+ transform-origin: 0 0;
27
+ transform: translate(var(--lk-overview-x), var(--lk-overview-y)) scale(var(--lk-overview-zoom));
28
+ pointer-events: none;
29
+ }
30
+
31
+ .lk-overview__canvas {
32
+ position: absolute;
33
+ inset: 0;
34
+ width: 100%;
35
+ height: 100%;
36
+ pointer-events: none;
37
+ }
38
+
39
+ .lk-overview__marks {
40
+ position: absolute;
41
+ left: var(--lk-overview-mx);
42
+ top: var(--lk-overview-my);
43
+ width: var(--lk-overview-mw);
44
+ height: var(--lk-overview-mh);
45
+ pointer-events: none;
46
+ }
47
+
48
+ .lk-overview__marks > svg {
49
+ display: block;
50
+ width: 100%;
51
+ height: 100%;
52
+ overflow: visible;
53
+ }
@@ -0,0 +1,50 @@
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { useState } from 'react';
3
+ import { Stage } from '../canvas/Stage';
4
+ import type { ViewTransform } from '../instrument/types';
5
+ import { TrialOverview } from './TrialOverview';
6
+
7
+ const meta: Meta<typeof TrialOverview> = {
8
+ title: 'labkit/Overview/TrialOverview',
9
+ component: TrialOverview,
10
+ parameters: { layout: 'fullscreen' },
11
+ };
12
+ export default meta;
13
+
14
+ type Story = StoryObj<typeof TrialOverview>;
15
+
16
+ const SIZE = { width: 640, height: 420 };
17
+ const DOTS = [40, 120, 200, 280, 360, 440, 520, 600];
18
+
19
+ /** A picture bigger than the stage at 2×, so there is somewhere to go. */
20
+ function Picture() {
21
+ return (
22
+ <svg viewBox="0 0 640 420" width={SIZE.width} height={SIZE.height} role="img" aria-label="Grid">
23
+ <title>Grid</title>
24
+ <rect width="640" height="420" fill="#f4f1ea" />
25
+ {DOTS.map((x, i) => (
26
+ <circle key={x} cx={x} cy={40 + i * 48} r="24" fill="#8b98a8" />
27
+ ))}
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ function Harness() {
33
+ const [view, setView] = useState<ViewTransform>({ zoom: 2, pan: { x: -200, y: -120 } });
34
+ return (
35
+ <div style={{ width: '100vw', height: '100vh' }}>
36
+ <Stage
37
+ size={SIZE}
38
+ view={view}
39
+ onViewChange={setView}
40
+ overlay={<TrialOverview width={200} height={140} render={() => <Picture />} />}
41
+ >
42
+ <Picture />
43
+ </Stage>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export const Default: Story = {
49
+ render: () => <Harness />,
50
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * A trial's overview: a press in it moves the stage's camera there, and the
3
+ * pointer over it is published as the overview's, which the stage then draws.
4
+ *
5
+ * jsdom lays nothing out, so every rect measures zero: a client point is
6
+ * already box-relative, and the stage's viewport has no size to center in —
7
+ * centering puts the pressed world point at the stage's origin instead.
8
+ */
9
+
10
+ import { act, render } from '@testing-library/react';
11
+ import type { PointerContextValue } from '@weasel-js/core';
12
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
13
+ import { defineInstrument } from '../instrument/defineInstrument';
14
+ import { Lab } from '../lab/Lab';
15
+ import { TrialOverview } from './TrialOverview';
16
+
17
+ beforeAll(() => {
18
+ HTMLCanvasElement.prototype.getContext = vi.fn(
19
+ () => null,
20
+ ) as unknown as HTMLCanvasElement['getContext'];
21
+ const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
22
+ proto.setPointerCapture = vi.fn();
23
+ proto.releasePointerCapture = vi.fn();
24
+ proto.hasPointerCapture = vi.fn(() => true);
25
+ });
26
+
27
+ function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
28
+ el.dispatchEvent(
29
+ new PointerEvent(type, {
30
+ bubbles: true,
31
+ cancelable: true,
32
+ pointerId: 1,
33
+ clientX: x,
34
+ clientY: y,
35
+ button: 0,
36
+ buttons,
37
+ }),
38
+ );
39
+ }
40
+
41
+ /** Content 400×300 in a 200×150 box, 6px in: scale 0.46, so box (100, 75)
42
+ * is content (200, 150). */
43
+ function mount() {
44
+ let store: PointerContextValue | null = null;
45
+ const pictured = defineInstrument<Record<string, never>, Record<string, never>>({
46
+ name: 'Pictured',
47
+ defaultConfig: () => ({}),
48
+ initialState: () => ({}),
49
+ render: (ctx) => {
50
+ store = ctx.trial.pointer;
51
+ return <div data-testid="picture" />;
52
+ },
53
+ stage: {
54
+ size: { width: 400, height: 300 },
55
+ overlay: () => (
56
+ <TrialOverview width={200} height={150} render={() => <div data-testid="thumb" />} />
57
+ ),
58
+ },
59
+ });
60
+ const r = render(<Lab instruments={[pictured]} defaultInstrument="Pictured" />);
61
+ const box = r.container.querySelector('.lk-overview__box');
62
+ const content = r.container.querySelector('.lk-stage__content') as HTMLElement | null;
63
+ if (!box || !content) throw new Error('no overview or stage');
64
+ return { ...r, box, content, store: () => store as PointerContextValue | null };
65
+ }
66
+
67
+ const px = (el: HTMLElement, name: string) => Number.parseFloat(el.style.getPropertyValue(name));
68
+
69
+ describe('<TrialOverview>', () => {
70
+ it('draws the instrument-supplied content, not the instrument itself', () => {
71
+ const h = mount();
72
+ expect(h.box.querySelector('[data-testid="thumb"]')).not.toBeNull();
73
+ expect(h.box.querySelector('[data-testid="picture"]')).toBeNull();
74
+ });
75
+
76
+ it('moves the stage camera to the pressed point', () => {
77
+ const h = mount();
78
+ act(() => {
79
+ pointer(h.box, 'pointerdown', 100, 75);
80
+ pointer(h.box, 'pointerup', 100, 75, 0);
81
+ });
82
+ expect(px(h.content, '--lk-stage-x')).toBeCloseTo(-200);
83
+ expect(px(h.content, '--lk-stage-y')).toBeCloseTo(-150);
84
+ });
85
+
86
+ it('follows a drag with the camera, and never pans the stage by it', () => {
87
+ const h = mount();
88
+ act(() => {
89
+ pointer(h.box, 'pointerdown', 100, 75);
90
+ pointer(h.box, 'pointermove', 110, 75);
91
+ pointer(h.box, 'pointermove', 123, 75);
92
+ pointer(h.box, 'pointerup', 123, 75, 0);
93
+ });
94
+ // Box x 123 is content x 250 — a stage pan by the 23px drag would land
95
+ // elsewhere.
96
+ expect(px(h.content, '--lk-stage-x')).toBeCloseTo(-250);
97
+ });
98
+
99
+ it("publishes the pointer over it as the overview's, and the stage draws it", () => {
100
+ const h = mount();
101
+ act(() => pointer(h.box, 'pointermove', 100, 75, 0));
102
+ const p = h.store()?.get();
103
+ expect(p?.viewId).toBe('overview');
104
+ expect(p?.worldX).toBeCloseTo(200);
105
+ expect(p?.worldY).toBeCloseTo(150);
106
+ expect(h.container.querySelector('.lk-stage .lk-linked-cursor')).not.toBeNull();
107
+ });
108
+
109
+ it("publishes the stage's pointer as the stage's, so the stage draws none", () => {
110
+ const h = mount();
111
+ const host = h.container.querySelector('.lk-stage') as HTMLElement;
112
+ act(() => pointer(host, 'pointermove', 10, 10, 0));
113
+ expect(h.store()?.get()?.viewId).toBe('stage');
114
+ expect(h.container.querySelector('.lk-stage .lk-linked-cursor')).toBeNull();
115
+ });
116
+ });