@weasel-js/labkit 1.6.1 → 1.7.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 (256) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
  4. package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
  7. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +6 -4
  11. package/dist/chrome/index.js +11 -10
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/chunk-4KSC7NXL.js +15491 -0
  17. package/dist/chunk-4KSC7NXL.js.map +1 -0
  18. package/dist/{chunk-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/chunk-CZZPXUOU.js +3 -0
  21. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  22. package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
  23. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  24. package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
  25. package/dist/{chunk-GHGO52SO.js.map → chunk-E6NARGU7.js.map} +1 -1
  26. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  27. package/dist/chunk-EESJUSYR.js.map +1 -0
  28. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  29. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  30. package/dist/chunk-F4ACIK7Z.js +210 -0
  31. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  32. package/dist/{chunk-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
  33. package/dist/chunk-FB4MIZOO.js.map +1 -0
  34. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  35. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  36. package/dist/{chunk-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
  37. package/dist/chunk-GAHELLJT.js.map +1 -0
  38. package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
  39. package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
  40. package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
  41. package/dist/chunk-JFOJESJF.js.map +1 -0
  42. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  43. package/dist/chunk-PMLZP7RT.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
  45. package/dist/chunk-READTDQX.js.map +1 -0
  46. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  47. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
  49. package/dist/chunk-T6DLGEJB.js.map +1 -0
  50. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  51. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  52. package/dist/{chunk-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
  53. package/dist/chunk-VYVGVBGY.js.map +1 -0
  54. package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
  55. package/dist/chunk-WLMDTI2Q.js.map +1 -0
  56. package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
  57. package/dist/chunk-XE5HJOPL.js.map +1 -0
  58. package/dist/chunk-XVUKDSYV.js +38 -0
  59. package/dist/chunk-XVUKDSYV.js.map +1 -0
  60. package/dist/{chunk-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
  61. package/dist/chunk-YCFKW5RX.js.map +1 -0
  62. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  63. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  64. package/dist/config/index.d.ts +28 -13
  65. package/dist/config/index.js +4 -4
  66. package/dist/controls/index.d.ts +4 -7
  67. package/dist/controls/index.js +6 -5
  68. package/dist/dragdrop/index.d.ts +23 -3
  69. package/dist/dragdrop/index.js +4 -3
  70. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  71. package/dist/index.d.ts +72 -487
  72. package/dist/index.js +603 -441
  73. package/dist/index.js.map +1 -1
  74. package/dist/job/index.d.ts +8 -0
  75. package/dist/job/index.js +1 -1
  76. package/dist/layers/index.d.ts +6 -5
  77. package/dist/layers/index.js +3 -2
  78. package/dist/loupe/index.d.ts +100 -30
  79. package/dist/loupe/index.js +585 -6
  80. package/dist/loupe/index.js.map +1 -1
  81. package/dist/overview/index.d.ts +4 -2
  82. package/dist/overview/index.js +20 -18
  83. package/dist/overview/index.js.map +1 -1
  84. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  85. package/dist/passthrough/weasel-ui.d.ts +2 -2701
  86. package/dist/passthrough/weasel-ui.js +4 -3
  87. package/dist/primitives/index.d.ts +37 -2
  88. package/dist/primitives/index.js +9 -8
  89. package/dist/state/index.d.ts +7 -5
  90. package/dist/state/index.js +6 -6
  91. package/dist/styles.css +150 -126
  92. package/dist/surface/index.d.ts +10 -2
  93. package/dist/surface/index.js +1 -1
  94. package/dist/undo/index.d.ts +5 -3
  95. package/package.json +14 -13
  96. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  97. package/src/annotations/AnnotationOverlay.tsx +52 -33
  98. package/src/annotations/Annotations.less +0 -10
  99. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  100. package/src/annotations/MarkList.tsx +4 -8
  101. package/src/annotations/capture.browser.test.ts +41 -0
  102. package/src/annotations/capture.ts +14 -7
  103. package/src/annotations/drawOne.ts +2 -2
  104. package/src/annotations/frac.test.ts +44 -1
  105. package/src/annotations/frac.ts +46 -18
  106. package/src/annotations/index.ts +1 -0
  107. package/src/annotations/paint.test.ts +76 -46
  108. package/src/annotations/paint.ts +65 -43
  109. package/src/annotations/store.test.ts +212 -2
  110. package/src/annotations/store.ts +130 -27
  111. package/src/annotations/svgNodes.test.ts +28 -19
  112. package/src/annotations/svgNodes.ts +6 -6
  113. package/src/annotations/types.ts +29 -10
  114. package/src/canvas/AGENTS.md +1 -0
  115. package/src/canvas/CameraInput.test.tsx +30 -0
  116. package/src/canvas/CameraInput.tsx +60 -48
  117. package/src/canvas/CanvasStack.tsx +11 -6
  118. package/src/canvas/CanvasStackContext.ts +4 -1
  119. package/src/canvas/LinkedCursor.less +1 -1
  120. package/src/canvas/Stage.test.tsx +33 -4
  121. package/src/canvas/Stage.tsx +9 -4
  122. package/src/canvas/cameraRegistry.test.ts +55 -0
  123. package/src/canvas/cameraRegistry.ts +87 -0
  124. package/src/canvas/cameraZoom.test.ts +46 -0
  125. package/src/canvas/cameraZoom.ts +24 -0
  126. package/src/canvas/index.ts +14 -2
  127. package/src/canvas/sharedScope.test.tsx +94 -0
  128. package/src/canvas/worldSpec.ts +3 -1
  129. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  130. package/src/chrome/builtins.test.ts +7 -6
  131. package/src/chrome/builtins.tsx +5 -1
  132. package/src/chrome/index.ts +1 -1
  133. package/src/chrome/merge.test.ts +5 -0
  134. package/src/chrome/merge.ts +4 -2
  135. package/src/chrome/regions/PaletteRegion.less +0 -7
  136. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  137. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  138. package/src/chrome/regions/SidebarRegion.less +12 -2
  139. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  140. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  141. package/src/chrome/regions/ViewportRegion.less +6 -0
  142. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  143. package/src/chrome/types.ts +4 -2
  144. package/src/config/auto.ts +1 -0
  145. package/src/config/builder.test.ts +2 -1
  146. package/src/config/builder.ts +18 -5
  147. package/src/config/fromConfigField.test.ts +14 -0
  148. package/src/config/fromConfigField.ts +18 -4
  149. package/src/config/path.ts +2 -5
  150. package/src/config/resolve.ts +4 -0
  151. package/src/config/types.ts +14 -9
  152. package/src/controls/ControlPanel.less +1 -1
  153. package/src/controls/ControlPanel.stories.tsx +3 -2
  154. package/src/controls/ControlPanel.test.tsx +98 -11
  155. package/src/controls/ControlPanel.tsx +61 -2
  156. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  157. package/src/dragdrop/DragGhost.tsx +3 -0
  158. package/src/dragdrop/Palette.tsx +2 -0
  159. package/src/index.test.ts +5 -0
  160. package/src/index.ts +16 -2
  161. package/src/instrument/types.ts +0 -6
  162. package/src/job/useJob.ts +14 -9
  163. package/src/lab/Lab.tsx +88 -73
  164. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  165. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  166. package/src/lab/LabFullChrome.stories.tsx +1 -0
  167. package/src/lab/LabHeader.test.tsx +24 -14
  168. package/src/lab/LabHeader.tsx +11 -12
  169. package/src/lab/LabShell.less +9 -17
  170. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  171. package/src/lab/LabSwitcher.less +8 -6
  172. package/src/lab/LabZoom.test.tsx +223 -0
  173. package/src/lab/LabZoom.tsx +170 -0
  174. package/src/lab/Workspace.tsx +48 -52
  175. package/src/lab/index.ts +1 -0
  176. package/src/loupe/AGENTS.md +28 -15
  177. package/src/loupe/CanvasLoupe.tsx +2 -3
  178. package/src/loupe/DomLoupe.tsx +1 -7
  179. package/src/loupe/Loupe.less +16 -0
  180. package/src/loupe/LoupeGestures.tsx +1 -1
  181. package/src/loupe/TrialLoupe.tsx +120 -57
  182. package/src/loupe/index.ts +1 -6
  183. package/src/loupe/types.test.ts +3 -3
  184. package/src/loupe/types.ts +24 -27
  185. package/src/loupe/useLoupe.test.tsx +29 -4
  186. package/src/loupe/useLoupe.ts +20 -17
  187. package/src/overview/OverviewMarks.tsx +12 -4
  188. package/src/overview/TrialOverview.tsx +10 -8
  189. package/src/passthrough/weasel-ui.test.ts +2 -0
  190. package/src/passthrough/weasel-ui.ts +17 -5
  191. package/src/primitives/FloatingPanel.test.tsx +11 -0
  192. package/src/primitives/FloatingPanel.tsx +8 -7
  193. package/src/primitives/JobProgress.less +2 -1
  194. package/src/primitives/Legend.stories.tsx +1 -0
  195. package/src/primitives/Readout.browser.test.tsx +137 -0
  196. package/src/primitives/Readout.less +12 -0
  197. package/src/primitives/Readout.stories.tsx +58 -0
  198. package/src/primitives/Readout.test.tsx +93 -0
  199. package/src/primitives/Readout.tsx +56 -0
  200. package/src/primitives/Split.tsx +69 -41
  201. package/src/primitives/Toolbar.tsx +3 -3
  202. package/src/primitives/ZoomControl.less +1 -2
  203. package/src/primitives/index.ts +2 -0
  204. package/src/specimen/Specimen.stories.tsx +1 -0
  205. package/src/specimen/Specimen.tsx +2 -3
  206. package/src/state/Persistence.browser.test.tsx +47 -0
  207. package/src/state/store.test.ts +5 -1
  208. package/src/state/undock.ts +3 -0
  209. package/src/state/useOpenOnce.ts +20 -16
  210. package/src/state/usePersistedState.test.tsx +1 -1
  211. package/src/state/view.ts +1 -0
  212. package/src/styles.less +1 -0
  213. package/src/surface/index.ts +1 -1
  214. package/src/surface/useTiledSurface.test.tsx +14 -0
  215. package/src/surface/useTiledSurface.ts +11 -3
  216. package/src/theme/Interstellar.stories.tsx +3 -1
  217. package/src/theme/base.less +21 -47
  218. package/src/theme/interstellar.test.ts +1 -1
  219. package/src/theme/interstellar.theme.json +10 -3
  220. package/src/trial/Trial.annotations.test.tsx +73 -0
  221. package/src/trial/Trial.less +0 -29
  222. package/src/trial/Trial.loupe.test.tsx +28 -11
  223. package/src/trial/Trial.tsx +85 -67
  224. package/src/trial/TrialChrome.tsx +27 -6
  225. package/src/trial/index.ts +2 -0
  226. package/src/trial/loupeSwitch.ts +15 -0
  227. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
  228. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  229. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  230. package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
  231. package/dist/chunk-4TT3PNPR.js.map +0 -1
  232. package/dist/chunk-6FLXL3I7.js.map +0 -1
  233. package/dist/chunk-ASOK76ZH.js.map +0 -1
  234. package/dist/chunk-CGHF6RZV.js.map +0 -1
  235. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  236. package/dist/chunk-DWMSB4CA.js.map +0 -1
  237. package/dist/chunk-HXXTULDN.js.map +0 -1
  238. package/dist/chunk-IJJUK4FU.js.map +0 -1
  239. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  240. package/dist/chunk-KHK5FGER.js +0 -145
  241. package/dist/chunk-KHK5FGER.js.map +0 -1
  242. package/dist/chunk-NEXR37YJ.js.map +0 -1
  243. package/dist/chunk-O6FFFOHS.js.map +0 -1
  244. package/dist/chunk-QGMOA2YE.js.map +0 -1
  245. package/dist/chunk-RWNDDKOH.js.map +0 -1
  246. package/dist/chunk-S4NCI32V.js +0 -549
  247. package/dist/chunk-S4NCI32V.js.map +0 -1
  248. package/dist/chunk-SBXNQW4G.js +0 -3
  249. package/dist/chunk-SLAQPUI4.js.map +0 -1
  250. package/dist/chunk-TJ77A4A3.js.map +0 -1
  251. package/dist/chunk-TYN6MLXX.js +0 -13864
  252. package/dist/chunk-TYN6MLXX.js.map +0 -1
  253. package/dist/chunk-WPLAANH4.js.map +0 -1
  254. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  255. package/src/primitives/useRovingTabIndex.ts +0 -88
  256. package/src/state/Persistence.stories.tsx +0 -75
@@ -1,13 +1,17 @@
1
+ import {
2
+ type Box,
3
+ boundsOfCoords,
4
+ boxContainsBox,
5
+ boxContainsPoint,
6
+ type Rect,
7
+ } from '@weasel-js/geom';
1
8
  import type { FracPoint, FracRect } from './types';
2
9
 
3
10
  /** A mark's box in its target's world: CSS pixels at zoom 1. Matches weasel's
4
11
  * default `RectPose`, which is what the scene stores. */
5
- export interface WorldRect {
6
- x: number;
7
- y: number;
8
- width: number;
9
- height: number;
10
- }
12
+ export type WorldRect = Rect;
13
+
14
+ const boxOfFrac = (f: FracRect): Box => [f.x, f.y, f.x + f.w, f.y + f.h];
11
15
 
12
16
  /** Where a fraction lands on a content box of this size. */
13
17
  export function fracToWorld(f: FracRect, content: { w: number; h: number }): WorldRect {
@@ -46,22 +50,46 @@ export function roundFrac(f: FracRect): FracRect {
46
50
  /** Whether `pt` falls in `box`, widened by `tol` on every side so a hairline
47
51
  * mark stays reachable. */
48
52
  export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
49
- return (
50
- pt.x >= box.x - tol &&
51
- pt.x <= box.x + box.w + tol &&
52
- pt.y >= box.y - tol &&
53
- pt.y <= box.y + box.h + tol
54
- );
53
+ const [x0, y0, x1, y1] = boxOfFrac(box);
54
+ return boxContainsPoint([x0 - tol, y0 - tol, x1 + tol, y1 + tol], pt.x, pt.y);
55
55
  }
56
56
 
57
57
  /** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
58
58
  * not what it grazes — brushing selection is a different gesture, and this
59
59
  * answers false for two rects that merely overlap. */
60
60
  export function fracEncloses(outer: FracRect, inner: FracRect): boolean {
61
- return (
62
- inner.x >= outer.x &&
63
- inner.y >= outer.y &&
64
- inner.x + inner.w <= outer.x + outer.w &&
65
- inner.y + inner.h <= outer.y + outer.h
66
- );
61
+ return boxContainsBox(boxOfFrac(outer), boxOfFrac(inner));
62
+ }
63
+
64
+ /** The smallest box holding every point — zero-size for a single one, and
65
+ * `null` for none. */
66
+ export function boundsOf(points: readonly FracPoint[]): FracRect | null {
67
+ const b = boundsOfCoords(points.flatMap((p) => [p.x, p.y]));
68
+ return b && { x: b[0], y: b[1], w: b[2] - b[0], h: b[3] - b[1] };
69
+ }
70
+
71
+ /** Points restated as fractions of `box` itself, so they move and stretch with
72
+ * it. Unit-agnostic: `points` and `box` only have to share one. A box with no
73
+ * width or height puts every point on its edge along that axis. */
74
+ export function toShape(points: readonly FracPoint[], box: FracRect): FracPoint[] {
75
+ return points.map((p) => ({
76
+ x: box.w === 0 ? 0 : (p.x - box.x) / box.w,
77
+ y: box.h === 0 ? 0 : (p.y - box.y) / box.h,
78
+ }));
67
79
  }
80
+
81
+ /** `toShape`'s inverse: a shape placed in `box`, in `box`'s units. */
82
+ export function fromShape(shape: readonly FracPoint[], box: FracRect): FracPoint[] {
83
+ return shape.map((s) => ({ x: box.x + s.x * box.w, y: box.y + s.y * box.h }));
84
+ }
85
+
86
+ /** A world rect as the `{ x, y, w, h }` the shape helpers take. */
87
+ export const boxOf = (r: WorldRect): FracRect => ({ x: r.x, y: r.y, w: r.width, h: r.height });
88
+
89
+ const roundPoint = (p: FracPoint): FracPoint => ({
90
+ x: Math.round(p.x * DP) / DP,
91
+ y: Math.round(p.y * DP) / DP,
92
+ });
93
+
94
+ /** `roundFrac` for points. */
95
+ export const roundPoints = (pts: readonly FracPoint[]): FracPoint[] => pts.map(roundPoint);
@@ -23,6 +23,7 @@ export type {
23
23
  AnnotationMeaning,
24
24
  AnnotationPatch,
25
25
  AnnotationQuery,
26
+ AnnotationSelectionMode,
26
27
  AnnotationStatus,
27
28
  AnnotationsApi,
28
29
  AnnotationsCapability,
@@ -1,9 +1,11 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { markCommands } from './paint';
1
+ import { registerNodeShape, type SceneNode, shapeCoversPoint } from '@weasel-js/core';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import type { WorldRect } from './frac';
4
+ import { markCommands, POINT_MARK_SHAPE } from './paint';
3
5
  import type { AnnotationData, AnnotationKind, FracPoint } from './types';
4
6
 
5
- const CONTENT = { w: 200, h: 100 };
6
7
  const POSE = { x: 20, y: 10, width: 60, height: 40 };
8
+ const AT_50: WorldRect = { x: 50, y: 50, width: 0, height: 0 };
7
9
 
8
10
  function mark(kind: AnnotationKind, extra: Partial<AnnotationData> = {}) {
9
11
  return { pose: POSE, data: { target: 't', kind, ...extra } as AnnotationData };
@@ -17,20 +19,21 @@ function anchors(cmds: ReturnType<typeof markCommands>): number[] {
17
19
  return p.coords ? [...p.coords] : [];
18
20
  }
19
21
 
22
+ /** In fractions of `POSE`: (35, 10) and (80, 30) in world. */
20
23
  const ENDS: readonly FracPoint[] = [
21
- { x: 0.1, y: 0.2 },
22
- { x: 0.6, y: 0.9 },
24
+ { x: 0.25, y: 0 },
25
+ { x: 1, y: 0.5 },
23
26
  ];
24
27
 
25
28
  describe('markCommands', () => {
26
29
  it('draws a rect at the pose', () => {
27
- const [cmd] = markCommands(mark('rect'), CONTENT);
30
+ const [cmd] = markCommands(mark('rect'));
28
31
  expect(cmd).toMatchObject({ kind: 'path', path: { kind: 'rect', ...POSE } });
29
32
  expect((cmd as { fill?: unknown }).fill).toBeUndefined();
30
33
  });
31
34
 
32
35
  it('draws an ellipse inscribed in the pose', () => {
33
- const pts = anchors(markCommands(mark('ellipse'), CONTENT));
36
+ const pts = anchors(markCommands(mark('ellipse')));
34
37
  const xs = pts.filter((_, i) => i % 2 === 0);
35
38
  const ys = pts.filter((_, i) => i % 2 === 1);
36
39
  expect(Math.min(...xs)).toBeCloseTo(POSE.x, 4);
@@ -39,32 +42,16 @@ describe('markCommands', () => {
39
42
  expect(Math.max(...ys)).toBeCloseTo(POSE.y + POSE.height, 4);
40
43
  });
41
44
 
42
- it('runs a line between its two stored ends, converted to world', () => {
43
- expect(anchors(markCommands(mark('line', { points: ENDS }), CONTENT))).toEqual([
44
- 0.1 * 200,
45
- 0.2 * 100,
46
- 0.6 * 200,
47
- 0.9 * 100,
48
- ]);
45
+ it('runs a line between its two stored ends, placed in the pose', () => {
46
+ expect(anchors(markCommands(mark('line', { shape: ENDS })))).toEqual([35, 10, 80, 30]);
49
47
  });
50
48
 
51
- it('makes an arrow the same line, plus the head as its own command', () => {
52
- const cmds = markCommands(mark('arrow', { points: ENDS }), CONTENT);
49
+ it('makes an arrow the same line, carrying an end marker', () => {
50
+ const cmds = markCommands(mark('arrow', { shape: ENDS }));
51
+ expect(cmds).toHaveLength(1);
53
52
  const stroke = (cmds[0] as { stroke?: { markerEnd?: unknown } }).stroke;
54
53
  expect(stroke?.markerEnd).toBe('arrow');
55
- expect(anchors([cmds[0]])).toEqual([0.1 * 200, 0.2 * 100, 0.6 * 200, 0.9 * 100]);
56
-
57
- // The head has to be geometry, not just a field: `markerEnd` alone is
58
- // inert outside the kit's own node painter, which is what shipped an
59
- // arrow with no head.
60
- expect(cmds).toHaveLength(2);
61
- const head = anchors([cmds[1]]);
62
- expect(head.length).toBeGreaterThan(0);
63
- // Its vertex is the line's far end — computed, not eyeballed.
64
- const near = (v: number, t: number) => Math.abs(v - t) < 1;
65
- const xs = head.filter((_, i) => i % 2 === 0);
66
- const ys = head.filter((_, i) => i % 2 === 1);
67
- expect(xs.some((x, i) => near(x, 120) && near(ys[i] as number, 90))).toBe(true);
54
+ expect(anchors(cmds)).toEqual([35, 10, 80, 30]);
68
55
  });
69
56
 
70
57
  it('threads a freehand stroke through every point', () => {
@@ -73,15 +60,13 @@ describe('markCommands', () => {
73
60
  { x: 0.5, y: 0.5 },
74
61
  { x: 1, y: 0 },
75
62
  ];
76
- expect(anchors(markCommands(mark('stroke', { points: pts }), CONTENT))).toEqual([
77
- 0, 0, 100, 50, 200, 0,
78
- ]);
63
+ expect(anchors(markCommands(mark('stroke', { shape: pts })))).toEqual([20, 10, 50, 30, 80, 10]);
79
64
  });
80
65
 
81
- it('falls back to the pose diagonal when a points kind has no points', () => {
82
- // A stored mark from a writer that dropped `points` must still draw
66
+ it('falls back to the pose diagonal when a vertex kind has no shape', () => {
67
+ // A stored mark from a writer that dropped `shape` must still draw
83
68
  // something at the right place, not vanish.
84
- expect(anchors(markCommands(mark('line'), CONTENT))).toEqual([
69
+ expect(anchors(markCommands(mark('line')))).toEqual([
85
70
  POSE.x,
86
71
  POSE.y,
87
72
  POSE.x + POSE.width,
@@ -90,18 +75,18 @@ describe('markCommands', () => {
90
75
  });
91
76
 
92
77
  it('draws text at the pose, carrying the title', () => {
93
- const [cmd] = markCommands(mark('text', { title: 'missing edge' }), CONTENT);
78
+ const [cmd] = markCommands(mark('text', { title: 'missing edge' }));
94
79
  expect(cmd).toMatchObject({ kind: 'text', x: POSE.x, y: POSE.y });
95
80
  const runs = (cmd as { runs: { text: string }[] }).runs;
96
81
  expect(runs.map((r) => r.text).join('')).toBe('missing edge');
97
82
  });
98
83
 
99
84
  it('draws an untitled text mark as nothing rather than an empty run', () => {
100
- expect(markCommands(mark('text'), CONTENT)).toEqual([]);
85
+ expect(markCommands(mark('text'))).toEqual([]);
101
86
  });
102
87
 
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);
88
+ it('draws a point as a small unfilled ring centered on its position', () => {
89
+ const cmds = markCommands({ pose: AT_50, data: { target: 't', kind: 'point' } });
105
90
  expect(cmds).toHaveLength(1);
106
91
  expect((cmds[0] as { fill?: unknown }).fill).toBeUndefined();
107
92
  const pts = anchors(cmds);
@@ -115,9 +100,56 @@ describe('markCommands', () => {
115
100
  });
116
101
  });
117
102
 
103
+ describe('a point mark at different zooms', () => {
104
+ const POINT = { pose: AT_50, data: { target: 't', kind: 'point' } as AnnotationData };
105
+ const radiusAt = (zoom: number) => {
106
+ const xs = anchors(markCommands(POINT, {}, { x: zoom, y: zoom })).filter((_, i) => i % 2 === 0);
107
+ return (Math.max(...xs) - Math.min(...xs)) / 2;
108
+ };
109
+
110
+ it('sizes the ring in screen pixels, so its world radius scales inversely with zoom', () => {
111
+ expect(radiusAt(2)).toBeCloseTo(2, 4);
112
+ expect(radiusAt(4)).toBeCloseTo(1, 4);
113
+ });
114
+
115
+ it('keeps the ring round on screen under a non-uniform scale', () => {
116
+ const pts = anchors(markCommands(POINT, {}, { x: 2, y: 4 }));
117
+ const xs = pts.filter((_, i) => i % 2 === 0);
118
+ const ys = pts.filter((_, i) => i % 2 === 1);
119
+ expect((Math.max(...xs) - Math.min(...xs)) * 2).toBeCloseTo(
120
+ (Math.max(...ys) - Math.min(...ys)) * 4,
121
+ 4,
122
+ );
123
+ });
124
+ });
125
+
126
+ describe('picking a point mark', () => {
127
+ let dispose: () => void = () => {};
128
+ beforeEach(() => {
129
+ dispose = registerNodeShape(POINT_MARK_SHAPE, { priority: 'high' });
130
+ });
131
+ afterEach(() => dispose());
132
+
133
+ const pose: WorldRect = { x: 50, y: 50, width: 0, height: 0 };
134
+ const node = {
135
+ id: 'p',
136
+ kind: 'leaf',
137
+ layer: 'marks',
138
+ pose,
139
+ data: { target: 't', kind: 'point' },
140
+ } as unknown as SceneNode<AnnotationData, 'marks', WorldRect>;
141
+
142
+ // Ring radius 4px plus half the 2px outline: the ring's outer edge is 5px out.
143
+ it.each([1, 4])('reaches the ring it draws and no further, at zoom %i', (zoom) => {
144
+ const at = (px: number) => shapeCoversPoint(node, pose, 50 + px / zoom, 50, { scale: zoom });
145
+ expect(at(4.9)).toBe(true);
146
+ expect(at(5.5)).toBe(false);
147
+ });
148
+ });
149
+
118
150
  describe('markCommands styling', () => {
119
151
  it('takes a status color over the default', () => {
120
- const [cmd] = markCommands(mark('rect'), CONTENT, { color: '#30a46c' });
152
+ const [cmd] = markCommands(mark('rect'), { color: '#30a46c' });
121
153
  expect((cmd as { stroke?: { paint?: { color?: string } } }).stroke?.paint?.color).toBe(
122
154
  '#30a46c',
123
155
  );
@@ -125,21 +157,19 @@ describe('markCommands styling', () => {
125
157
 
126
158
  it('dashes a stale mark rather than hiding it', () => {
127
159
  // A stale mark still describes something; dropping it would lose it.
128
- const [fresh] = markCommands(mark('rect'), CONTENT);
129
- const [stale] = markCommands(mark('rect'), CONTENT, { stale: true });
160
+ const [fresh] = markCommands(mark('rect'));
161
+ const [stale] = markCommands(mark('rect'), { stale: true });
130
162
  expect((fresh as { stroke?: { dash?: number[] } }).stroke?.dash).toBeUndefined();
131
163
  expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
132
164
  });
133
165
 
134
166
  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
- });
167
+ const [ring] = markCommands(mark('point'), { stale: true });
138
168
  expect((ring as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
139
169
  });
140
170
 
141
171
  it("colors a text mark's glyphs too, not only the outlines", () => {
142
- const [cmd] = markCommands(mark('text', { title: 'x' }), CONTENT, { color: '#30a46c' });
172
+ const [cmd] = markCommands(mark('text', { title: 'x' }), { color: '#30a46c' });
143
173
  const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
144
174
  expect(runs[0]?.fill?.color).toBe('#30a46c');
145
175
  });
@@ -1,23 +1,28 @@
1
- import type { DrawCommand, Path, Stroke } from '@weasel-js/core';
1
+ import type { DrawCommand, NodeShapeEntry, Path, Stroke, View } from '@weasel-js/core';
2
2
  import {
3
3
  ellipsePath,
4
+ HANDLE_BASE_PX,
4
5
  linePath,
5
- markerDrawCommands,
6
+ meanScale,
6
7
  PathBuilder,
8
+ pxExtent,
7
9
  rectPath,
8
10
  textCommand,
9
11
  } from '@weasel-js/core';
10
- import type { WorldRect } from './frac';
11
- import type { AnnotationData, FracPoint } from './types';
12
+ import { boxOf, fromShape, type WorldRect } from './frac';
13
+ import type { AnnotationData } from './types';
12
14
 
13
15
  /** One loud color, not a themed one: a mark sits over the instrument's own
14
16
  * picture and has to be legible against whatever that picture is. */
15
17
  const MARK_COLOR = '#e5484d';
16
- /** World units, so a mark thickens with the picture it annotates — and so the
17
- * marker geometry can be resolved here, which needs a resolved width. */
18
+ /** World units, so a mark thickens with the picture it annotates. */
18
19
  const MARK_WIDTH = 2;
19
20
  const TEXT_SIZE = 14;
20
- const POINT_RADIUS = 4;
21
+ const STALE_DASH = [6, 4];
22
+ /** Screen pixels: a point marks a place rather than a region of the picture,
23
+ * so it holds its size like a selection handle. */
24
+ const POINT_RADIUS_PX = HANDLE_BASE_PX / 2;
25
+ const UNIT_SCALE: View['scale'] = { x: 1, y: 1 };
21
26
 
22
27
  /** The subset of a mark's scene node this needs: where it is, and what it is. */
23
28
  export interface PaintableMark {
@@ -36,16 +41,11 @@ export interface MarkStyle {
36
41
  stale?: boolean;
37
42
  }
38
43
 
39
- const toWorld = (p: FracPoint, content: { w: number; h: number }) => ({
40
- x: p.x * content.w,
41
- y: p.y * content.h,
42
- });
43
-
44
- /** A mark's stored vertices in world units, or the pose's diagonal — a stored
45
- * mark whose `points` did not survive still has to draw somewhere. */
46
- function vertices(m: PaintableMark, content: { w: number; h: number }): { x: number; y: number }[] {
47
- const stored = m.data.points;
48
- if (stored && stored.length >= 2) return stored.map((p) => toWorld(p, content));
44
+ /** A mark's stored vertices placed in its pose, or the pose's diagonal — a
45
+ * stored mark whose `shape` did not survive still has to draw somewhere. */
46
+ function vertices(m: PaintableMark): { x: number; y: number }[] {
47
+ const stored = m.data.shape;
48
+ if (stored && stored.length >= 2) return fromShape(stored, boxOf(m.pose));
49
49
  const { x, y, width, height } = m.pose;
50
50
  return [
51
51
  { x, y },
@@ -65,14 +65,18 @@ function polyline(points: { x: number; y: number }[]): Path {
65
65
  /**
66
66
  * What one mark draws, in its target's world.
67
67
  *
68
- * Pure: a node and the target's content box in, draw commands out. Geometry
69
- * that a bounding box cannot describe — a line's ends, a stroke's path — comes
70
- * from `data.points`, which is in fractions like the bounds.
68
+ * Pure: a node in, draw commands out, in its target's world. Geometry that a
69
+ * bounding box cannot describe — a line's ends, a stroke's path — comes from
70
+ * `data.shape`, placed in the pose.
71
+ *
72
+ * `scale` is the view's, world to screen. Only a point reads it — its ring is
73
+ * sized in screen pixels — and the default draws that ring as though a world
74
+ * unit were a pixel.
71
75
  */
72
76
  export function markCommands(
73
77
  m: PaintableMark,
74
- content: { w: number; h: number },
75
78
  style: MarkStyle = {},
79
+ scale: View['scale'] = UNIT_SCALE,
76
80
  ): DrawCommand[] {
77
81
  const color = style.color ?? MARK_COLOR;
78
82
  const stroke: Stroke = {
@@ -80,7 +84,7 @@ export function markCommands(
80
84
  width: MARK_WIDTH,
81
85
  cap: 'round',
82
86
  join: 'round',
83
- ...(style.stale ? { dash: [6, 4] } : {}),
87
+ ...(style.stale ? { dash: STALE_DASH } : {}),
84
88
  };
85
89
 
86
90
  switch (m.data.kind) {
@@ -91,34 +95,32 @@ export function markCommands(
91
95
  case 'ellipse':
92
96
  return [{ kind: 'path', path: ellipsePath(m.pose), stroke }];
93
97
  case 'line': {
94
- const [a, b] = vertices(m, content);
98
+ const [a, b] = vertices(m);
95
99
  return [{ kind: 'path', path: linePath(a, b), stroke }];
96
100
  }
97
101
  case 'arrow': {
98
- const [a, b] = vertices(m, content);
102
+ const [a, b] = vertices(m);
99
103
  // The spec's arrow: a line carrying an end marker, not its own geometry.
100
- // Markers are separate commands — a stroke's `markerEnd` is inert unless
101
- // something turns it into geometry, and only the kit's own node painter
102
- // does that, for the nodes it owns.
103
- const marked: Stroke = { ...stroke, markerEnd: 'arrow' };
104
- const path = linePath(a, b);
105
- return [
106
- { kind: 'path', path, stroke: marked },
107
- ...markerDrawCommands(path, marked, MARK_WIDTH, undefined),
108
- ];
104
+ return [{ kind: 'path', path: linePath(a, b), stroke: { ...stroke, markerEnd: 'arrow' } }];
109
105
  }
110
106
  case 'stroke':
111
- return [{ kind: 'path', path: polyline(vertices(m, content)), stroke }];
107
+ return [{ kind: 'path', path: polyline(vertices(m)), stroke }];
112
108
  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 }];
109
+ const c = { x: m.pose.x, y: m.pose.y };
110
+ const r = pxExtent(POINT_RADIUS_PX, scale);
111
+ const ring = { x: c.x - r.x, y: c.y - r.y, width: 2 * r.x, height: 2 * r.y };
112
+ const px = 1 / meanScale(scale);
113
+ return [
114
+ {
115
+ kind: 'path',
116
+ path: ellipsePath(ring),
117
+ stroke: {
118
+ ...stroke,
119
+ width: MARK_WIDTH * px,
120
+ ...(stroke.dash ? { dash: stroke.dash.map((d) => d * px) } : {}),
121
+ },
122
+ },
123
+ ];
122
124
  }
123
125
  case 'text': {
124
126
  const text = m.data.title;
@@ -140,3 +142,23 @@ export function markCommands(
140
142
  }
141
143
  }
142
144
  }
145
+
146
+ /**
147
+ * What picking reads for a point mark: its pose is zero-size, so without this
148
+ * the pointer slop alone decides how close counts, whatever the ring's size.
149
+ * Registered by the overlay, which is the one place marks are picked.
150
+ */
151
+ export const POINT_MARK_SHAPE: NodeShapeEntry<AnnotationData, WorldRect> = {
152
+ id: 'labkit:point-mark',
153
+ matches: (node) =>
154
+ node.layer === 'marks' && node.data?.kind === 'point' && typeof node.data.target === 'string',
155
+ // The overlay paints through its own draw callback. This is for a scene
156
+ // drawn without one.
157
+ paint: (node, pose) => markCommands({ pose, data: node.data }),
158
+ silhouette: (_node, pose) => rectPath(pose.x, pose.y, 0, 0),
159
+ // Filled, so a click inside the ring lands on it as it would on a handle.
160
+ ink: (_node, _pose, ctx) => {
161
+ const s = ctx?.scale ?? 1;
162
+ return { filled: true, outset: (POINT_RADIUS_PX + MARK_WIDTH / 2) / s, inset: 0 };
163
+ },
164
+ };