@weasel-js/labkit 1.3.0 → 1.4.0-pre.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 (211) hide show
  1. package/README.md +226 -55
  2. package/dist/_dts/CanvasStackContext-LnCfqNBA.d.ts +42 -0
  3. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  4. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  5. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  6. package/dist/_dts/frac-C-2c72Ij.d.ts +735 -0
  7. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  8. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  9. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  10. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  11. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  12. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  13. package/dist/canvas/index.d.ts +110 -26
  14. package/dist/canvas/index.js +5 -4
  15. package/dist/chrome/index.d.ts +9 -6
  16. package/dist/chrome/index.js +6 -6
  17. package/dist/chunk-2AYGEN57.js +32 -0
  18. package/dist/chunk-2AYGEN57.js.map +1 -0
  19. package/dist/chunk-6OEYTYML.js +669 -0
  20. package/dist/chunk-6OEYTYML.js.map +1 -0
  21. package/dist/{chunk-BMW4TDP5.js → chunk-AE5CNVRU.js} +3 -3
  22. package/dist/{chunk-BMW4TDP5.js.map → chunk-AE5CNVRU.js.map} +1 -1
  23. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  24. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  25. package/dist/chunk-CTRKTLYZ.js +33 -0
  26. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  27. package/dist/chunk-E2UQVZ44.js +458 -0
  28. package/dist/chunk-E2UQVZ44.js.map +1 -0
  29. package/dist/{chunk-LGKRIUSW.js → chunk-ESIQNQ6K.js} +32 -7
  30. package/dist/chunk-ESIQNQ6K.js.map +1 -0
  31. package/dist/{chunk-JDROYQM3.js → chunk-HHGVISVZ.js} +130 -131
  32. package/dist/chunk-HHGVISVZ.js.map +1 -0
  33. package/dist/{chunk-BDJXIBRZ.js → chunk-HMFODCOX.js} +126 -29
  34. package/dist/chunk-HMFODCOX.js.map +1 -0
  35. package/dist/{chunk-QCICIHEO.js → chunk-L5QJOOLV.js} +839 -320
  36. package/dist/chunk-L5QJOOLV.js.map +1 -0
  37. package/dist/{chunk-3XXPU73K.js → chunk-M75ZU6ZZ.js} +9 -7
  38. package/dist/chunk-M75ZU6ZZ.js.map +1 -0
  39. package/dist/{chunk-QRYPXSGP.js → chunk-QB5SM4BM.js} +16 -8
  40. package/dist/chunk-QB5SM4BM.js.map +1 -0
  41. package/dist/chunk-RBGKL7NF.js +7128 -0
  42. package/dist/chunk-RBGKL7NF.js.map +1 -0
  43. package/dist/{chunk-WTI26YTM.js → chunk-TO2FUOKF.js} +40 -13
  44. package/dist/chunk-TO2FUOKF.js.map +1 -0
  45. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  46. package/dist/chunk-XJ6N32QP.js.map +1 -0
  47. package/dist/controls/index.d.ts +4 -28
  48. package/dist/controls/index.js +3 -3
  49. package/dist/dragdrop/index.d.ts +9 -5
  50. package/dist/dragdrop/index.js +3 -3
  51. package/dist/index.d.ts +517 -234
  52. package/dist/index.js +1769 -143
  53. package/dist/index.js.map +1 -1
  54. package/dist/layers/index.d.ts +6 -5
  55. package/dist/layers/index.js +3 -3
  56. package/dist/loupe/index.d.ts +187 -0
  57. package/dist/loupe/index.js +7 -0
  58. package/dist/loupe/index.js.map +1 -0
  59. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  60. package/dist/passthrough/weasel-canvas.js +1 -1
  61. package/dist/passthrough/weasel-ui.d.ts +138 -91
  62. package/dist/passthrough/weasel-ui.js +2 -2
  63. package/dist/primitives/index.d.ts +3 -1
  64. package/dist/primitives/index.js +5 -5
  65. package/dist/state/index.d.ts +3 -3
  66. package/dist/state/index.js +2 -2
  67. package/dist/styles.css +229 -1
  68. package/dist/surface/index.d.ts +18 -2
  69. package/dist/surface/index.js +2 -2
  70. package/dist/ui/layers/index.js +2 -2
  71. package/dist/undo/index.d.ts +5 -4
  72. package/package.json +14 -7
  73. package/src/annotations/AnnotationOverlay.tsx +205 -0
  74. package/src/annotations/AnnotationTargets.tsx +37 -0
  75. package/src/annotations/Annotations.less +129 -0
  76. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  78. package/src/annotations/AnnotationsContext.ts +49 -0
  79. package/src/annotations/ExportMenu.test.tsx +86 -0
  80. package/src/annotations/ExportMenu.tsx +182 -0
  81. package/src/annotations/MarkList.tsx +75 -0
  82. package/src/annotations/capture.test.ts +140 -0
  83. package/src/annotations/capture.ts +237 -0
  84. package/src/annotations/drawOne.test.ts +56 -0
  85. package/src/annotations/drawOne.ts +47 -0
  86. package/src/annotations/frac.test.ts +59 -0
  87. package/src/annotations/frac.ts +66 -0
  88. package/src/annotations/history.test.ts +82 -0
  89. package/src/annotations/history.ts +118 -0
  90. package/src/annotations/index.ts +37 -0
  91. package/src/annotations/paint.test.ts +125 -0
  92. package/src/annotations/paint.ts +130 -0
  93. package/src/annotations/staleness.test.ts +56 -0
  94. package/src/annotations/staleness.ts +41 -0
  95. package/src/annotations/store.test.ts +247 -0
  96. package/src/annotations/store.ts +347 -0
  97. package/src/annotations/svgNodes.test.ts +77 -0
  98. package/src/annotations/svgNodes.ts +74 -0
  99. package/src/annotations/toolMap.test.ts +36 -0
  100. package/src/annotations/toolMap.ts +54 -0
  101. package/src/annotations/types.ts +229 -0
  102. package/src/annotations/view.test.ts +37 -0
  103. package/src/annotations/view.ts +40 -0
  104. package/src/canvas/AGENTS.md +55 -5
  105. package/src/canvas/CanvasStack.test.tsx +27 -1
  106. package/src/canvas/CanvasStack.tsx +29 -4
  107. package/src/canvas/CanvasStackContext.ts +23 -4
  108. package/src/canvas/camera.test.ts +78 -0
  109. package/src/canvas/camera.ts +62 -0
  110. package/src/canvas/canvasCoords.test.ts +26 -0
  111. package/src/canvas/canvasCoords.ts +20 -7
  112. package/src/canvas/index.ts +16 -1
  113. package/src/canvas/useLayerScheduler.ts +7 -3
  114. package/src/canvas/usePanZoom.test.ts +36 -5
  115. package/src/canvas/usePanZoom.ts +20 -15
  116. package/src/canvas/worldSpec.test.ts +63 -0
  117. package/src/canvas/worldSpec.ts +48 -0
  118. package/src/chrome/builtins.test.ts +31 -1
  119. package/src/chrome/builtins.tsx +63 -19
  120. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  121. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  122. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  123. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  124. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  125. package/src/chrome/regions/regions.test.tsx +6 -1
  126. package/src/chrome/types.ts +20 -0
  127. package/src/controls/ControlPanel.test.tsx +38 -0
  128. package/src/controls/ControlPanel.tsx +50 -3
  129. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  130. package/src/dragdrop/dragDrop.test.tsx +29 -2
  131. package/src/index.test.ts +23 -0
  132. package/src/index.ts +93 -4
  133. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  134. package/src/instrument/types.ts +24 -1
  135. package/src/lab/Lab.surface.test.tsx +162 -0
  136. package/src/lab/Lab.tsx +102 -17
  137. package/src/lab/Lab.undock.test.tsx +85 -0
  138. package/src/lab/LabFullChrome.stories.tsx +20 -0
  139. package/src/lab/LabShell.less +11 -0
  140. package/src/lab/Workspace.less +73 -0
  141. package/src/lab/Workspace.surface.test.tsx +2 -0
  142. package/src/lab/Workspace.tsx +97 -8
  143. package/src/lab/index.ts +1 -1
  144. package/src/lab/panelHost.ts +37 -0
  145. package/src/loupe/AGENTS.md +49 -0
  146. package/src/loupe/CanvasLoupe.tsx +83 -0
  147. package/src/loupe/DomLoupe.tsx +62 -0
  148. package/src/loupe/Loupe.less +38 -0
  149. package/src/loupe/LoupeBubble.tsx +32 -0
  150. package/src/loupe/TrialLoupe.tsx +93 -0
  151. package/src/loupe/canvasLens.test.ts +205 -0
  152. package/src/loupe/canvasLens.ts +141 -0
  153. package/src/loupe/index.ts +19 -0
  154. package/src/loupe/types.test.ts +29 -0
  155. package/src/loupe/types.ts +90 -0
  156. package/src/loupe/useHostSize.ts +30 -0
  157. package/src/loupe/useLoupe.test.tsx +197 -0
  158. package/src/loupe/useLoupe.ts +189 -0
  159. package/src/passthrough/weasel-ui.ts +3 -0
  160. package/src/primitives/FloatingPanel.test.tsx +76 -1
  161. package/src/primitives/FloatingPanel.tsx +19 -3
  162. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  163. package/src/primitives/Sidebar.less +18 -0
  164. package/src/primitives/Toolbar.less +8 -0
  165. package/src/primitives/Toolbar.tsx +5 -0
  166. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  167. package/src/primitives/useRovingTabIndex.ts +26 -17
  168. package/src/state/document.test.ts +23 -0
  169. package/src/state/document.ts +14 -2
  170. package/src/state/index.ts +2 -0
  171. package/src/state/store.test.ts +24 -0
  172. package/src/state/store.ts +35 -1
  173. package/src/state/types.ts +11 -0
  174. package/src/state/undock.test.ts +40 -0
  175. package/src/state/undock.ts +39 -0
  176. package/src/styles.less +2 -0
  177. package/src/surface/SurfaceContext.ts +4 -0
  178. package/src/surface/index.ts +8 -3
  179. package/src/surface/useSurfaceTile.test.tsx +2 -0
  180. package/src/surface/useSurfaceTile.ts +7 -1
  181. package/src/surface/useTiledSurface.test.tsx +58 -0
  182. package/src/surface/useTiledSurface.ts +38 -3
  183. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  184. package/src/trial/Trial.annotations.test.tsx +121 -0
  185. package/src/trial/Trial.canvas.test.tsx +76 -1
  186. package/src/trial/Trial.less +19 -0
  187. package/src/trial/Trial.loupe.test.tsx +128 -0
  188. package/src/trial/Trial.stories.tsx +1 -0
  189. package/src/trial/Trial.test.tsx +12 -0
  190. package/src/trial/Trial.trialId.test.tsx +36 -0
  191. package/src/trial/Trial.tsx +215 -40
  192. package/src/trial/TrialChrome.tsx +35 -0
  193. package/src/trial/UndockedSections.tsx +65 -0
  194. package/src/trial/trialOps.test.ts +21 -0
  195. package/src/trial/trialOps.ts +5 -3
  196. package/dist/_dts/types-BttTIent.d.ts +0 -314
  197. package/dist/chunk-3XXPU73K.js.map +0 -1
  198. package/dist/chunk-4TMMVIDM.js.map +0 -1
  199. package/dist/chunk-6X5RBC6G.js +0 -366
  200. package/dist/chunk-6X5RBC6G.js.map +0 -1
  201. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  202. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  203. package/dist/chunk-JDROYQM3.js.map +0 -1
  204. package/dist/chunk-LGKRIUSW.js.map +0 -1
  205. package/dist/chunk-QCICIHEO.js.map +0 -1
  206. package/dist/chunk-QRYPXSGP.js.map +0 -1
  207. package/dist/chunk-RGT4EJIB.js +0 -6961
  208. package/dist/chunk-RGT4EJIB.js.map +0 -1
  209. package/dist/chunk-T66HU3HX.js +0 -17
  210. package/dist/chunk-T66HU3HX.js.map +0 -1
  211. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -0,0 +1,229 @@
1
+ import type { RefObject } from 'react';
2
+ import type { ViewTransform } from '../instrument/types';
3
+ import type { MarkScene } from './store';
4
+
5
+ /** A point in fractions of a target's content box. */
6
+ export interface FracPoint {
7
+ x: number;
8
+ y: number;
9
+ }
10
+
11
+ /** A rectangle in fractions of a target's content box.
12
+ *
13
+ * Fractions rather than pixels because a mark must survive a change of render
14
+ * resolution: the picture gets bigger, the mark stays on the same feature. */
15
+ export interface FracRect {
16
+ x: number;
17
+ y: number;
18
+ w: number;
19
+ h: number;
20
+ }
21
+
22
+ /** What shape a mark is. The kinds map onto weasel's own tools; an arrow is a
23
+ * line carrying an end marker, not a separate geometry. */
24
+ export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text';
25
+
26
+ /** One selectable status in an instrument's meaning tier. */
27
+ export interface AnnotationStatus {
28
+ id: string;
29
+ label: string;
30
+ /** What a mark in this status is drawn in. Omitted, it takes the default
31
+ * mark colour — a status is allowed to be a label and nothing more. */
32
+ color?: string;
33
+ }
34
+
35
+ /** The optional meaning tier: what a mark means, as opposed to where it is.
36
+ * An instrument declaring it gets labkit's own chrome for the vocabulary;
37
+ * one that omits it keeps `meta` and owns meaning entirely. */
38
+ export interface AnnotationMeaning {
39
+ statuses?: readonly AnnotationStatus[];
40
+ }
41
+
42
+ /** What labkit keeps in a mark's scene-node data. */
43
+ export interface AnnotationData {
44
+ target: string;
45
+ kind: AnnotationKind;
46
+ /** Vertices for the kinds a bounding box cannot describe — a line's two
47
+ * ends, a stroke's path. In fractions, like the bounds. */
48
+ points?: readonly FracPoint[];
49
+ title?: string;
50
+ status?: string;
51
+ tags?: readonly string[];
52
+ meta?: unknown;
53
+ /** The target's `positionDependsOn` values when the mark was made. Compared
54
+ * against the live config to answer whether the mark still describes the
55
+ * same picture. */
56
+ seen?: Readonly<Record<string, unknown>>;
57
+ }
58
+
59
+ /** A mark, as the store reports it. `id` is `<target>/<node>`: one scene per
60
+ * target means a node id is only unique within one. */
61
+ export interface Annotation extends AnnotationData {
62
+ id: string;
63
+ /** Bounds in fractions of the target's content box. */
64
+ frac: FracRect;
65
+ }
66
+
67
+ /** A new mark, before the store gives it an id and dates it. */
68
+ export interface AnnotationInit {
69
+ target: string;
70
+ kind: AnnotationKind;
71
+ frac: FracRect;
72
+ points?: readonly FracPoint[];
73
+ title?: string;
74
+ status?: string;
75
+ tags?: readonly string[];
76
+ meta?: unknown;
77
+ }
78
+
79
+ /** Filters, ANDed. A mark matches `tags` when it carries every tag listed. */
80
+ export interface AnnotationQuery {
81
+ target?: string;
82
+ kind?: AnnotationKind;
83
+ status?: string;
84
+ tags?: readonly string[];
85
+ where?: (a: Annotation) => boolean;
86
+ }
87
+
88
+ /** What a mark's meaning can be patched to. Geometry moves through `frac` /
89
+ * `points`; everything else is the meaning tier. */
90
+ export type AnnotationPatch = Partial<
91
+ Pick<Annotation, 'frac' | 'points' | 'title' | 'status' | 'tags' | 'meta'>
92
+ >;
93
+
94
+ /** A target's own picture, handed over for an export to draw marks on top of.
95
+ * labkit cannot rasterize it: it is the consumer's DOM. `svg` is the one that
96
+ * keeps the export vector all the way through. */
97
+ export type CaptureSource =
98
+ | { kind: 'svg'; markup: string }
99
+ | { kind: 'image'; src: string }
100
+ | { kind: 'canvas'; canvas: HTMLCanvasElement };
101
+
102
+ /** What an export produces, and at what resolution. */
103
+ export interface CaptureOptions {
104
+ /** `png` rasterizes; `svg` stays vector, which needs the base to be one too
105
+ * — a raster base embeds as an `<image>`. Default `png`. */
106
+ format?: 'png' | 'svg';
107
+ /** Output pixels per unit of the target's content box. Default 2. */
108
+ scale?: number;
109
+ }
110
+
111
+ /** A finished export. `width`/`height` are the output's, not the content
112
+ * box's — `content × scale`. */
113
+ export interface CaptureResult {
114
+ target: string;
115
+ blob: Blob;
116
+ format: 'png' | 'svg';
117
+ width: number;
118
+ height: number;
119
+ }
120
+
121
+ /** What the store needs to know about a target: enough to convert a position
122
+ * and to date a mark. `AnnotationTarget` adds what only the overlay reads. */
123
+ export interface AnnotationTargetInfo {
124
+ id: string;
125
+ /** Intrinsic content size in CSS pixels at zoom 1 — the box fractions are
126
+ * fractions *of*, and the target's world. */
127
+ content: { w: number; h: number };
128
+ /** Config keys whose change means a stored position no longer refers to the
129
+ * same picture. labkit snapshots and compares them without knowing what any
130
+ * of them mean. */
131
+ positionDependsOn?: readonly string[];
132
+ /** The target's own picture, for an export to draw marks over. A target
133
+ * declaring none exports its marks on transparency, which fails visibly
134
+ * rather than producing a blank brick.
135
+ *
136
+ * Here rather than on `AnnotationTarget` because the store is what calls
137
+ * it: `ref` and `view` are the React-shaped half only the overlay reads. */
138
+ base?: () => CaptureSource | Promise<CaptureSource>;
139
+ }
140
+
141
+ /** One region of an instrument that accepts marks. */
142
+ export interface AnnotationTarget extends AnnotationTargetInfo {
143
+ /** The element the overlay tracks and takes input from. */
144
+ ref: RefObject<HTMLElement | null>;
145
+ /** The pane's camera, mirrored so marks pan and zoom with what they mark. */
146
+ view?: ViewTransform;
147
+ }
148
+
149
+ /** Where an instrument keeps its own marks. Declaring this means labkit never
150
+ * writes its trial slot — for an instrument whose marks belong in a format it
151
+ * already owns. Both halves are called outside React; `save` is already
152
+ * debounced by the time it arrives. */
153
+ export interface AnnotationStorage {
154
+ load: () => SerializedAnnotations | null | undefined;
155
+ save: (doc: SerializedAnnotations) => void;
156
+ }
157
+
158
+ /** Declares that an instrument accepts marks: which regions take them,
159
+ * optionally what a mark is allowed to mean, and optionally where they live. */
160
+ export interface AnnotationsCapability<TS = unknown, TC = unknown> {
161
+ targets: (state: TS, config: TC) => readonly AnnotationTarget[];
162
+ meaning?: AnnotationMeaning;
163
+ storage?: AnnotationStorage;
164
+ /** Fires after every finished export, labkit's own chrome included. A
165
+ * notification, not an interception: a host wanting its own flow calls
166
+ * `capture()` from its own UI, which is the surface the chrome uses. */
167
+ onCapture?: (result: CaptureResult) => void;
168
+ }
169
+
170
+ /** A persisted mark set. Versioned by this arc rather than by labkit's
171
+ * document migrations, which only ever reach top-level document sections and
172
+ * never into a trial's state. */
173
+ export interface SerializedAnnotations {
174
+ version: 1;
175
+ /** One serialized scene per target that has ever held a mark. */
176
+ scenes: Record<string, unknown>;
177
+ }
178
+
179
+ /** Everything a host can ask or tell labkit about the marks on its targets. */
180
+ export interface AnnotationsApi {
181
+ /** The scene a target's marks live in, created on first ask. One per target
182
+ * because a pane's hit-test, marquee and paint walk the whole scene they
183
+ * are given: a shared one would put a neighbour's marks under the pointer. */
184
+ sceneFor(target: string): MarkScene;
185
+ /** The targets the instrument declares, in declaration order. Chrome needs
186
+ * the list and cannot get it from the capability, which wants instrument
187
+ * state the chrome context does not carry. */
188
+ targets(): readonly AnnotationTargetInfo[];
189
+ get(id: string): Annotation | undefined;
190
+ /** Every mark matching the filters, in scene order. Omit `q` for all. */
191
+ query(q?: AnnotationQuery): Annotation[];
192
+ /** Marks whose bounds contain `pt`, topmost first. `tol` widens the hit in
193
+ * fractions, so a hairline mark stays reachable. */
194
+ hitTest(target: string, pt: FracPoint, tol?: number): Annotation[];
195
+ /** Marks wholly inside `box` — a marquee, not a brush. */
196
+ within(target: string, box: FracRect): Annotation[];
197
+ /** Whether `a`'s position still describes the picture `config` produces. */
198
+ isStale(a: Annotation, config: unknown): boolean;
199
+ /** The marks the user currently has selected, across every target, as
200
+ * annotation ids. Every one resolves through `get`. */
201
+ selection(): readonly string[];
202
+ /** Replace the selection. An id naming a target or a mark that is not
203
+ * there is dropped, the way `update` and `remove` ignore one. */
204
+ setSelection(ids: readonly string[]): void;
205
+ /** Fires after every mutation *and* after a selection change — weasel keeps
206
+ * a canvas's selection on the scene, so both already arrive on this one
207
+ * channel. No delta: re-query, and re-read `selection()`. */
208
+ subscribe(fn: () => void): () => void;
209
+
210
+ /** Whether the last mark change on any target can be taken back. Weasel
211
+ * history is the authority; this only decides *which* target's. */
212
+ canUndo(): boolean;
213
+ canRedo(): boolean;
214
+ /** Take back the most recent mark change, wherever it was made. */
215
+ undo(): boolean;
216
+ redo(): boolean;
217
+
218
+ /** Export a target's picture with its marks on it. Rejects on an id the
219
+ * instrument does not declare. */
220
+ capture(target: string, opts?: CaptureOptions): Promise<CaptureResult>;
221
+
222
+ add(init: AnnotationInit, config?: unknown): string;
223
+ update(id: string, patch: AnnotationPatch): void;
224
+ setMeta(id: string, meta: unknown): void;
225
+ remove(id: string): void;
226
+
227
+ /** A JSON-safe snapshot for `record.state`. */
228
+ toJSON(): SerializedAnnotations;
229
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fitView, fromWeaselView, toWeaselView } from './view';
3
+
4
+ describe('fitView', () => {
5
+ it("scales a target's content box to the box it is drawn in", () => {
6
+ expect(fitView({ w: 256, h: 170 }, { w: 512, h: 340 })).toEqual({
7
+ zoom: 2,
8
+ pan: { x: 0, y: 0 },
9
+ });
10
+ });
11
+
12
+ it('takes the smaller axis, so nothing is drawn past the pane', () => {
13
+ expect(fitView({ w: 256, h: 170 }, { w: 512, h: 170 })).toEqual({
14
+ zoom: 1,
15
+ pan: { x: 0, y: 0 },
16
+ });
17
+ });
18
+
19
+ it('reads zoom 1 from a degenerate content box rather than Infinity', () => {
20
+ expect(fitView({ w: 0, h: 0 }, { w: 512, h: 340 })).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
21
+ });
22
+ });
23
+
24
+ describe('view conversion', () => {
25
+ it("round-trips a target's camera through weasel's shape", () => {
26
+ const v = { zoom: 1.5, pan: { x: -12, y: 40 } };
27
+ expect(fromWeaselView(toWeaselView(v))).toEqual(v);
28
+ });
29
+
30
+ it("puts the pan in weasel's translation and the zoom in both scale axes", () => {
31
+ expect(toWeaselView({ zoom: 2, pan: { x: 10, y: -5 } })).toEqual({
32
+ x: 10,
33
+ y: -5,
34
+ scale: { x: 2, y: 2 },
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,40 @@
1
+ import type { View } from '@weasel-js/core';
2
+ import type { ViewTransform } from '../instrument/types';
3
+
4
+ /** A target's world in CSS pixels: the box its fractions are fractions of. */
5
+ export interface ContentSize {
6
+ w: number;
7
+ h: number;
8
+ }
9
+
10
+ /** A target's drawn box on the surface, in CSS pixels. */
11
+ export interface PaneSize {
12
+ w: number;
13
+ h: number;
14
+ }
15
+
16
+ /** labkit's camera in weasel's shape. Zoom is uniform, so it lands on both
17
+ * scale axes; pan is weasel's translation unchanged. */
18
+ export function toWeaselView(v: ViewTransform): View {
19
+ return { x: v.pan.x, y: v.pan.y, scale: { x: v.zoom, y: v.zoom } };
20
+ }
21
+
22
+ /** The inverse. A weasel view with unequal axis scales collapses to its x —
23
+ * labkit's camera has one zoom and cannot hold the other. */
24
+ export function fromWeaselView(v: View): ViewTransform {
25
+ return { zoom: v.scale.x, pan: { x: v.x, y: v.y } };
26
+ }
27
+
28
+ /**
29
+ * The camera a target with none of its own draws through: its content box
30
+ * scaled to fit the box it is drawn in, anchored at the origin.
31
+ *
32
+ * The spec calls the camera "a plain scale" at zoom 1; zoom 1 is only right
33
+ * when the pane happens to be the content's size, and a mark drawn on a
34
+ * scaled-down pane otherwise lands outside it.
35
+ */
36
+ export function fitView(content: ContentSize, pane: PaneSize): ViewTransform {
37
+ const zoom =
38
+ content.w > 0 && content.h > 0 ? Math.min(pane.w / content.w, pane.h / content.h) : 1;
39
+ return { zoom: Number.isFinite(zoom) && zoom > 0 ? zoom : 1, pan: { x: 0, y: 0 } };
40
+ }
@@ -18,7 +18,9 @@ into a camera is the host's job.
18
18
  | `CanvasStackContext.ts` | React context exposing the current `view` to descendants |
19
19
  | `useLayerScheduler.ts` | DPR-aware rAF scheduler; redraws dirty layers on view/state changes |
20
20
  | `usePanZoom.ts` | Pointer + wheel handlers that mutate `view` via `onViewChange` |
21
+ | `camera.ts` | `zoomAt` (fixed-point zoom) and `centerOn` (put a world point at a viewport's middle) |
21
22
  | `canvasCoords.ts` | Pure `screenToWorld` / `worldToScreen` helpers |
23
+ | `worldSpec.ts` | The instrument's declared coordinate system, and the camera derived from it |
22
24
  | `CanvasStack.less` | Container + canvas + overlay positioning |
23
25
 
24
26
  ## Props (`<CanvasStack>`)
@@ -28,6 +30,8 @@ interface CanvasStackProps {
28
30
  layers: CanvasLayerDescriptor[]; // { id, visible, render(ctx, view) }
29
31
  view: ViewTransform; // { zoom, pan: { x, y } }
30
32
  onViewChange: (v: ViewTransform) => void;
33
+ worldSpec?: WorldSpec; // origin + y direction; default top-left, y down
34
+ onResize?: (size: ViewportSize) => void;
31
35
  minZoom?: number; // default 0.1, forwarded to usePanZoom
32
36
  maxZoom?: number; // default 32, forwarded to usePanZoom
33
37
  width?: number | string; // default '100%'
@@ -51,6 +55,7 @@ A layer is defined by the instrument's `canvas.layers[]` (type `CanvasLayer`), t
51
55
 
52
56
  ```ts
53
57
  canvas: {
58
+ worldSpec: { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }, // optional
54
59
  layers: [
55
60
  {
56
61
  id: 'grid',
@@ -60,9 +65,9 @@ canvas: {
60
65
  }
61
66
  ```
62
67
 
63
- `draw` receives the context with the camera already applied, so its coordinates are world coordinates — `Trial.tsx` translates by `view.pan` and scales by `view.zoom` before calling it. `zoom` is still passed so a layer can keep line widths and handle sizes from growing: divide by it (`ctx.lineWidth = 1 / zoom`).
68
+ `draw` receives the context with the camera already applied, so its coordinates are world coordinates — `Trial.tsx` calls `applyCamera(ctx, view, frame)` before calling it. `zoom` is still passed so a layer can keep line widths and handle sizes from growing: divide by it (`ctx.lineWidth = 1 / zoom`).
64
69
 
65
- That is the difference between the two layer types. The lower-level `CanvasLayerDescriptor.render(ctx, view)` gets the raw view and an untransformed context, which is what screen-space chrome wants.
70
+ That is the difference between the two layer types. The lower-level `CanvasLayerDescriptor.render(ctx, view, frame)` gets the raw view, the resolved frame and an untransformed context, which is what screen-space chrome wants.
66
71
 
67
72
  Layer order in the array = paint order (first drawn = bottom).
68
73
 
@@ -70,17 +75,62 @@ Layer order in the array = paint order (first drawn = bottom).
70
75
 
71
76
  The `children` prop renders in `.lk-canvas-stack__overlay`, which is `position: absolute; inset: 0; pointer-events: none`. Direct children re-enable pointer events. Use this for HUDs, scale indicators, or interactive overlays that don't belong in a canvas layer.
72
77
 
78
+ ## The world spec
79
+
80
+ An instrument's world does not have to be labkit's. `CanvasCapability.worldSpec`
81
+ declares two things, and everything that maps between world and screen reads
82
+ them:
83
+
84
+ ```ts
85
+ worldSpec: { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' } // centred, y upward
86
+ ```
87
+
88
+ `origin` is a **fraction of the viewport** — where world (0,0) sits at `pan`
89
+ zero — so "centre" is `0.5` and needs no knowledge of the canvas size. `yAxis`
90
+ is `'down'` (default) or `'up'`. Omitting the spec entirely gives the original
91
+ convention: origin at the element's top-left, y downward.
92
+
93
+ `resolveFrame(spec, size)` turns a spec plus a measured viewport into a
94
+ `WorldFrame` — `{ originPx, yDir }` — which is what the rest of the directory
95
+ actually consumes. `CanvasStack` owns the measurement, so it owns the frame and
96
+ hands it to `usePanZoom`, to the scheduler, and to every `render`.
97
+
98
+ **Every world↔screen path must go through the frame.** There are four, and each
99
+ one is a silent wrong answer if it is missed: `worldToScreen` / `screenToWorld`,
100
+ the camera (`applyCamera`), the wheel anchor in `usePanZoom`, and
101
+ `DragDropRuntime`'s drop position. A miss produces geometry that is off by a
102
+ constant, or a wheel that drifts — never an error.
103
+
73
104
  ## Coordinate conversions
74
105
 
75
- - `screenToWorld({x, y}, view)` → world coords given a CSS-pixel offset relative to the container's top-left
76
- - `worldToScreen({x, y}, view)` → inverse
106
+ - `screenToWorld({x, y}, view, frame?)` → world coords given a CSS-pixel offset relative to the container's top-left
107
+ - `worldToScreen({x, y}, view, frame?)` → inverse
108
+ - `applyCamera(ctx, view, frame?)` → puts a 2D context into world coordinates; the exact inverse of `screenToWorld`
77
109
 
78
- Both are pure. Pan is applied in screen space; zoom multiplies world coordinates.
110
+ All pure, and all default to `DEFAULT_FRAME` (top-left, y down).
79
111
 
80
112
  ## Pan/zoom behavior
81
113
 
82
114
  Implemented in `usePanZoom`. Mouse-wheel zoom is anchored at the cursor; primary-button drag pans. `isDragging()` is exposed so consumers can suppress click handlers during a pan.
83
115
 
116
+ The anchor is taken **in frame space** (`cursor - frame.originPx`), because
117
+ `pan` is measured from the frame's origin. Anchoring at the raw cursor instead
118
+ drifts by `(1 - ratio) * originPx` on every wheel step for any frame that moves
119
+ the origin — around 180x110 px per step on a centred 1430x870 canvas, with no
120
+ error raised. The anchoring itself is `zoomAt` in `camera.ts`, which the loupe's wheel also
121
+ uses — one fixed-point zoom rather than a copy per caller. Do not reach for
122
+ `zoomAt` from `@weasel-js/core` instead: that one clamps
123
+ `min(max, max(min, scale * factor))` per axis with positive defaults, so a y-up
124
+ view (`scale.y` negative) comes back at `+0.1` — flipped and collapsed. labkit's
125
+ holds one scalar zoom and keeps the y direction in the frame, so it has no such
126
+ axis to invert.
127
+
128
+ `CanvasCapability.initialView` may be a function of the viewport size instead of
129
+ a literal. labkit then leaves the trial's view `null` until the canvas is first
130
+ measured and places it from `onResize` — so an instrument framing content it can
131
+ only size against the viewport does not need a "have I centred yet" sentinel of
132
+ its own. Reset nulls the view again, which re-frames.
133
+
84
134
  Zoom is clamped to `[minZoom, maxZoom]` (defaults 0.1 / 32), settable via `CanvasStack`'s props and, for an instrument's own canvas, `CanvasCapability.minZoom` / `maxZoom`. The clamp always widens to admit whatever zoom the canvas opened at — an instrument declaring `initialView.zoom: 1600` with the default range is not clamped down to 32 on the first wheel event.
85
135
 
86
136
  ## Testing notes
@@ -1,4 +1,4 @@
1
- import { render } from '@testing-library/react';
1
+ import { fireEvent, render } from '@testing-library/react';
2
2
  import { beforeAll, describe, expect, it, vi } from 'vitest';
3
3
  import { CanvasStack } from './CanvasStack';
4
4
  import type { CanvasLayerDescriptor } from './useLayerScheduler';
@@ -79,4 +79,30 @@ describe('<CanvasStack>', () => {
79
79
  );
80
80
  expect(getByText('overlay-content')).toBeInTheDocument();
81
81
  });
82
+
83
+ it('hit-tests through the declared world spec', () => {
84
+ const rect = { left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 };
85
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
86
+ rect as unknown as DOMRect,
87
+ );
88
+ const onHitTest = vi.fn();
89
+ const { container } = render(
90
+ <CanvasStack
91
+ layers={makeLayers(1)}
92
+ view={{ zoom: 2, pan: { x: 0, y: 0 } }}
93
+ onViewChange={vi.fn()}
94
+ worldSpec={{ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }}
95
+ onHitTest={onHitTest}
96
+ />,
97
+ );
98
+ const host = container.querySelector('.lk-canvas-stack');
99
+ if (!host) throw new Error('no stack host');
100
+ fireEvent.pointerDown(host, { button: 0, pointerId: 1, clientX: 500, clientY: 200 });
101
+ fireEvent.pointerUp(host, { pointerId: 1, clientX: 500, clientY: 200 });
102
+
103
+ // Origin is (400,300); the cursor is 100px right of it and 100px above it,
104
+ // at zoom 2 with y running up.
105
+ expect(onHitTest).toHaveBeenCalledWith({ x: 50, y: 50 });
106
+ vi.restoreAllMocks();
107
+ });
82
108
  });
@@ -4,17 +4,24 @@ import { CanvasStackContext } from './CanvasStackContext';
4
4
  import { screenToWorld } from './canvasCoords';
5
5
  import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
6
6
  import { usePanZoom } from './usePanZoom';
7
+ import { resolveFrame, type ViewportSize, type WorldSpec } from './worldSpec';
7
8
 
8
9
  /** Props for `<CanvasStack>`. */
9
10
  export interface CanvasStackProps {
10
11
  layers: CanvasLayerDescriptor[];
11
12
  view: ViewTransform;
12
13
  onViewChange: (v: ViewTransform) => void;
14
+ /** The instrument's coordinate system. Omitted, world (0,0) sits at the
15
+ * element's top-left with y running down. */
16
+ worldSpec?: WorldSpec;
13
17
  minZoom?: number;
14
18
  maxZoom?: number;
15
19
  width?: number | string;
16
20
  height?: number | string;
17
21
  className?: string;
22
+ /** Fired whenever the stack is measured, so a consumer can place a view that
23
+ * only makes sense in terms of the viewport. */
24
+ onResize?: (size: ViewportSize) => void;
18
25
  onHitTest?: (worldPos: Point) => void;
19
26
  children?: ReactNode;
20
27
  }
@@ -26,11 +33,13 @@ export function CanvasStack({
26
33
  layers,
27
34
  view,
28
35
  onViewChange,
36
+ worldSpec,
29
37
  minZoom,
30
38
  maxZoom,
31
39
  width = '100%',
32
40
  height = '100%',
33
41
  className,
42
+ onResize,
34
43
  onHitTest,
35
44
  children,
36
45
  }: CanvasStackProps) {
@@ -63,10 +72,26 @@ export function CanvasStack({
63
72
  return () => ro.disconnect();
64
73
  }, []);
65
74
 
66
- const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom });
67
- useLayerScheduler({ layers, view, canvasRefs: canvasMap, size, host: containerRef });
75
+ const onResizeRef = useRef(onResize);
76
+ onResizeRef.current = onResize;
77
+ useEffect(() => {
78
+ if (size.width === 0 && size.height === 0) return;
79
+ onResizeRef.current?.({ width: size.width, height: size.height });
80
+ }, [size.width, size.height]);
81
+
82
+ const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
68
83
 
69
- const ctxValue = useMemo(() => ({ view }), [view]);
84
+ const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame });
85
+ useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
86
+
87
+ const ctxValue = useMemo(
88
+ () => ({
89
+ view,
90
+ frame,
91
+ surface: { element: containerRef, size, canvases: canvasMap, layers },
92
+ }),
93
+ [view, frame, size, layers],
94
+ );
70
95
 
71
96
  const containerStyle: CSSProperties = { width, height };
72
97
  const canvasPx = {
@@ -84,7 +109,7 @@ export function CanvasStack({
84
109
  if (!wasDragging && onHitTest && containerRef.current) {
85
110
  const rect = containerRef.current.getBoundingClientRect();
86
111
  const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
87
- onHitTest(screenToWorld(screen, view));
112
+ onHitTest(screenToWorld(screen, view, frame));
88
113
  }
89
114
  };
90
115
 
@@ -1,11 +1,30 @@
1
- import { createContext } from 'react';
1
+ import { createContext, type RefObject } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
+ import type { CanvasLayerDescriptor } from './useLayerScheduler';
4
+ import type { WorldFrame } from './worldSpec';
3
5
 
4
- /** What a canvas stack publishes to its children — currently the view, so
5
- * DOM overlays can position themselves in the same coordinates. */
6
+ /** The stack's own drawing surface, for an overlay that has to re-draw it at
7
+ * another camera or read back what it presented — a loupe. */
8
+ export interface CanvasStackSurface {
9
+ /** The element the layers are stacked in, and the one pan/zoom listens on. */
10
+ element: RefObject<HTMLElement | null>;
11
+ /** Its measured CSS size, and the ratio the backing stores are scaled by. */
12
+ size: { width: number; height: number; dpr: number };
13
+ /** The presented `<canvas>` per layer id. */
14
+ canvases: RefObject<Map<string, HTMLCanvasElement>>;
15
+ /** The layers as the stack is drawing them, bottom first. */
16
+ layers: readonly CanvasLayerDescriptor[];
17
+ }
18
+
19
+ /** What a canvas stack publishes to its children: the view, and the resolved
20
+ * coordinate system it is read in, so DOM overlays can place themselves in the
21
+ * same coordinates the layers draw in. */
6
22
  export interface CanvasStackContextValue {
7
23
  view: ViewTransform;
24
+ frame: WorldFrame;
25
+ /** Absent from a context assembled by hand, which has no stack behind it. */
26
+ surface?: CanvasStackSurface;
8
27
  }
9
28
 
10
- /** Context carrying the surrounding canvas stack's view. */
29
+ /** Context carrying the surrounding canvas stack's view and world frame. */
11
30
  export const CanvasStackContext = createContext<CanvasStackContextValue | null>(null);
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { centerOn, zoomAt } from './camera';
3
+ import { screenToWorld, worldToScreen } from './canvasCoords';
4
+ import { DEFAULT_FRAME, resolveFrame } from './worldSpec';
5
+
6
+ const view = { zoom: 2, pan: { x: 30, y: -10 } };
7
+
8
+ describe('zoomAt', () => {
9
+ it('multiplies the zoom by the factor', () => {
10
+ expect(zoomAt(view, 3, { x: 0, y: 0 }).zoom).toBe(6);
11
+ });
12
+
13
+ it('keeps the world point under the anchor fixed', () => {
14
+ const at = { x: 120, y: 80 };
15
+ const before = screenToWorld(at, view);
16
+ const next = zoomAt(view, 3, at);
17
+ expect(screenToWorld(at, next).x).toBeCloseTo(before.x, 10);
18
+ expect(screenToWorld(at, next).y).toBeCloseTo(before.y, 10);
19
+ });
20
+
21
+ it('keeps it fixed on a frame whose origin is not the top-left', () => {
22
+ const frame = resolveFrame(
23
+ { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' },
24
+ {
25
+ width: 400,
26
+ height: 300,
27
+ },
28
+ );
29
+ const at = { x: 120, y: 80 };
30
+ const before = screenToWorld(at, view, frame);
31
+ const next = zoomAt(view, 3, at, { frame });
32
+ expect(screenToWorld(at, next, frame).x).toBeCloseTo(before.x, 10);
33
+ expect(screenToWorld(at, next, frame).y).toBeCloseTo(before.y, 10);
34
+ });
35
+
36
+ it('clamps to the bounds, and anchors on the zoom it actually reached', () => {
37
+ const at = { x: 120, y: 80 };
38
+ const next = zoomAt(view, 100, at, { max: 8 });
39
+ expect(next.zoom).toBe(8);
40
+ const before = screenToWorld(at, view);
41
+ expect(screenToWorld(at, next).x).toBeCloseTo(before.x, 10);
42
+ });
43
+
44
+ it('treats a non-finite zoom as 1 rather than propagating NaN', () => {
45
+ const next = zoomAt({ zoom: Number.NaN, pan: { x: 0, y: 0 } }, 2, { x: 10, y: 10 });
46
+ expect(next.zoom).toBe(2);
47
+ expect(Number.isFinite(next.pan.x)).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe('centerOn', () => {
52
+ it('puts the world point at the middle of the viewport', () => {
53
+ const world = { x: 12, y: -4 };
54
+ const v = centerOn(world, 6, { width: 200, height: 120 });
55
+ const p = worldToScreen(world, v);
56
+ expect(p.x).toBeCloseTo(100, 10);
57
+ expect(p.y).toBeCloseTo(60, 10);
58
+ });
59
+
60
+ it('respects the frame it is given', () => {
61
+ const frame = resolveFrame(
62
+ { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' },
63
+ {
64
+ width: 200,
65
+ height: 120,
66
+ },
67
+ );
68
+ const world = { x: 12, y: -4 };
69
+ const v = centerOn(world, 6, { width: 200, height: 120 }, frame);
70
+ const p = worldToScreen(world, v, frame);
71
+ expect(p.x).toBeCloseTo(100, 10);
72
+ expect(p.y).toBeCloseTo(60, 10);
73
+ });
74
+
75
+ it('carries the zoom it was given', () => {
76
+ expect(centerOn({ x: 0, y: 0 }, 6, { width: 10, height: 10 }, DEFAULT_FRAME).zoom).toBe(6);
77
+ });
78
+ });