@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
@@ -18,7 +18,6 @@ interface Box {
18
18
 
19
19
  /** Every tile's box expressed against the container's origin. */
20
20
  declare function composeRects(container: Box, tiles: ReadonlyMap<string, Box>): Map<string, Rect>;
21
- declare function rectsEqual(a: Rect | undefined, b: Rect): boolean;
22
21
 
23
22
  /**
24
23
  * A DOM rect as a GL viewport rect: origin at the bottom-left, every edge snapped
@@ -82,9 +81,18 @@ type SurfaceClear = (size: {
82
81
  width: number;
83
82
  height: number;
84
83
  }, dpr: number) => void;
84
+ /** Inputs to `useTiledSurface`. `onFrame` runs once per frame that has dirty
85
+ * tiles, before any tile paints — the place to size the shared buffer. */
85
86
  interface UseTiledSurfaceOptions {
86
87
  onFrame: (frame: SurfaceFrame) => void;
87
88
  }
89
+ /**
90
+ * Share one drawing buffer among tiles laid out by the DOM. Tiles register
91
+ * their elements; the hook measures them against the container, re-measures on
92
+ * resize, and batches invalidations into one visible-only frame that calls
93
+ * `onFrame`, then (on a retiled frame) the registered clears, then each dirty
94
+ * tile's painter.
95
+ */
88
96
  declare function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHandle;
89
97
 
90
98
  /** Null when no surface owner is above — a lab with no shared surface at all. */
@@ -141,5 +149,5 @@ declare function useTileId(id: string): string;
141
149
  */
142
150
  declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
143
151
 
144
- export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface };
152
+ export { SurfaceCanvasContext, SurfaceContext, composeRects, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface };
145
153
  export type { Box, Rect, SurfaceCanvases, SurfaceClear, SurfaceFrame, SurfaceHandle, SurfaceLayer, TilePainter, UseTiledSurfaceOptions };
@@ -1,4 +1,4 @@
1
- export { composeRects, rectsEqual, toDeviceRect, useTiledSurface } from '../chunk-IJJUK4FU.js';
1
+ export { composeRects, toDeviceRect, useTiledSurface } from '../chunk-EESJUSYR.js';
2
2
  export { SurfaceCanvasContext, SurfaceContext, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../chunk-WIG6XHJ7.js';
3
3
  import '../chunk-2YILQ7Y5.js';
4
4
  //# sourceMappingURL=index.js.map
@@ -1,9 +1,11 @@
1
- export { az as UndoCapability } from '../_dts/types-B7yBTTF9.js';
1
+ export { at as UndoCapability } from '../_dts/index-DCMA4Fy1.js';
2
+ import '@weasel-js/ui';
2
3
  import '@weasel-js/core';
3
4
  import 'react';
4
- import '../_dts/PrefsRow.d-DFpPIeIC.js';
5
5
  import '@weasel-js/theme';
6
- import '../_dts/types-DTuViBvc.js';
6
+ import '../_dts/types-BUBSIZuV.js';
7
+ import '@weasel-js/quantity';
8
+ import '@weasel-js/geom';
7
9
  import '../_dts/types-DJ79Tg5J.js';
8
10
 
9
11
  /** A subscriber to a named event. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -115,28 +115,29 @@
115
115
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
116
116
  },
117
117
  "peerDependencies": {
118
- "@weasel-js/core": "1.6.1",
119
- "@weasel-js/theme": "1.6.1",
118
+ "@weasel-js/core": "1.7.1",
119
+ "@weasel-js/theme": "1.7.1",
120
120
  "react": "^19.0.0",
121
121
  "react-dom": "^19.0.0"
122
122
  },
123
123
  "dependencies": {
124
- "@weasel-js/geom": "1.6.1",
125
- "@weasel-js/kernel3d": "1.6.1",
126
- "@weasel-js/loupe": "1.6.1",
127
- "@weasel-js/svg": "1.6.1",
128
- "@weasel-js/ui": "1.6.1",
129
- "earcut": "2.2.4",
124
+ "@weasel-js/geom": "1.7.1",
125
+ "@weasel-js/kernel3d": "1.7.1",
126
+ "@weasel-js/loupe": "1.7.1",
127
+ "@weasel-js/quantity": "1.7.1",
128
+ "@weasel-js/svg": "1.7.1",
129
+ "@weasel-js/ui": "1.7.1",
130
+ "earcut": "3.2.3",
130
131
  "polygon-clipping": "^0.15.7",
131
132
  "react-aria-components": "^1.5.0",
132
- "windease": "^1.3.0",
133
+ "windease": "^2.0.0",
133
134
  "zustand": "^5.0.12"
134
135
  },
135
136
  "devDependencies": {
136
137
  "@biomejs/biome": "^2.4.12",
137
- "@rollup/plugin-alias": "^6.0.0",
138
- "@testing-library/jest-dom": "^6.6.0",
139
- "@weasel-js/modes": "1.6.1",
138
+ "@testing-library/jest-dom": "^7.0.1",
139
+ "@weasel-js/guides": "1.7.1",
140
+ "@weasel-js/modes": "1.7.1",
140
141
  "less": "^4.2.0",
141
142
  "rollup-plugin-dts": "^6.4.1",
142
143
  "tsx": "^4.19.0",
@@ -0,0 +1,143 @@
1
+ /**
2
+ * What an overlay reads outside render — the insert factory's tool and config,
3
+ * and the buffer its clear opens — comes from the render that committed, never
4
+ * from one React started and threw away.
5
+ *
6
+ * `<SceneCanvas>` is stubbed: jsdom has no WebGL2, so the real one never
7
+ * paints, and a pane that has not painted never clears. The stub hands the
8
+ * overlay a frame subscription the test can fire, and keeps the committed
9
+ * insert factories.
10
+ */
11
+ import type { InsertNodeFactory, SceneCanvasApi } from '@weasel-js/core';
12
+ import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
13
+ import { type Ref, useLayoutEffect } from 'react';
14
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
15
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
16
+ import type { SurfaceClear, SurfaceHandle, TilePainter } from '../surface/useTiledSurface';
17
+ import { AnnotationOverlay } from './AnnotationOverlay';
18
+ import { seenFrom } from './staleness';
19
+ import { createAnnotationScene } from './store';
20
+ import type { AnnotationTarget } from './types';
21
+
22
+ const frames = new Set<() => void>();
23
+ let factories: Record<string, InsertNodeFactory> = {};
24
+
25
+ vi.mock('@weasel-js/core', async (importOriginal) => {
26
+ const actual = await importOriginal<typeof import('@weasel-js/core')>();
27
+ function SceneCanvas(props: {
28
+ ref?: Ref<SceneCanvasApi | null>;
29
+ insertNodeFactories: Record<string, InsertNodeFactory>;
30
+ }) {
31
+ const { ref, insertNodeFactories } = props;
32
+ useLayoutEffect(() => {
33
+ factories = insertNodeFactories;
34
+ });
35
+ useLayoutEffect(() => {
36
+ if (typeof ref !== 'function') return;
37
+ const api = {
38
+ subscribeFrame: (fn: () => void) => {
39
+ frames.add(fn);
40
+ return () => frames.delete(fn);
41
+ },
42
+ requestRedraw: () => {},
43
+ } as unknown as SceneCanvasApi;
44
+ ref(api);
45
+ return () => {
46
+ ref(null);
47
+ };
48
+ }, [ref]);
49
+ return null;
50
+ }
51
+ return { ...actual, SceneCanvas };
52
+ });
53
+
54
+ beforeAll(() => {
55
+ HTMLCanvasElement.prototype.getContext = vi.fn(
56
+ () => null,
57
+ ) as unknown as HTMLCanvasElement['getContext'];
58
+ });
59
+
60
+ /** A surface that measures every tile at once, so the pane mounts its canvas
61
+ * inside the first commit. */
62
+ function surfaceFor(clears: SurfaceClear[]): SurfaceHandle {
63
+ const container = document.createElement('div');
64
+ return {
65
+ invalidate: () => {},
66
+ invalidateAll: () => {},
67
+ invalidateRects: () => {},
68
+ registerTile: () => {},
69
+ registerClear: (_id, clear) => {
70
+ clears.push(clear);
71
+ return () => {};
72
+ },
73
+ registerPainter: (_id, paint: TilePainter) => {
74
+ paint({ x: 0, y: 0, w: 100, h: 50 }, {} as never);
75
+ return () => {};
76
+ },
77
+ containerRef: () => {},
78
+ getContainer: () => container,
79
+ } as SurfaceHandle;
80
+ }
81
+
82
+ const target: AnnotationTarget = {
83
+ id: 'pane',
84
+ ref: { current: null },
85
+ content: { w: 100, h: 50 },
86
+ positionDependsOn: ['k'],
87
+ };
88
+
89
+ interface Props {
90
+ toolId: string;
91
+ config: { k: number };
92
+ canvas: HTMLCanvasElement;
93
+ }
94
+
95
+ const committed = document.createElement('canvas');
96
+ const abandoned = document.createElement('canvas');
97
+
98
+ /** Commits a pane holding `line` on `committed`, then abandons one holding
99
+ * `arrow` on `abandoned`. Returns the clears the pane registered. */
100
+ function mountThenAbandon(): SurfaceClear[] {
101
+ const clears: SurfaceClear[] = [];
102
+ const surface = surfaceFor(clears);
103
+ const scene = createAnnotationScene();
104
+ renderThenAbandon<Props>(
105
+ { toolId: 'line', config: { k: 1 }, canvas: committed },
106
+ { toolId: 'arrow', config: { k: 2 }, canvas: abandoned },
107
+ (p) => (
108
+ <SurfaceContext.Provider value={surface}>
109
+ <SurfaceCanvasContext.Provider value={{ over: p.canvas, under: null }}>
110
+ <AnnotationOverlay
111
+ target={target}
112
+ scene={scene}
113
+ config={p.config}
114
+ activeToolId={p.toolId}
115
+ />
116
+ </SurfaceCanvasContext.Provider>
117
+ </SurfaceContext.Provider>
118
+ ),
119
+ );
120
+ return clears;
121
+ }
122
+
123
+ describe('<AnnotationOverlay> across an abandoned render', () => {
124
+ it('mints a mark with the committed tool and config', () => {
125
+ mountThenAbandon();
126
+ // `line` and `arrow` drive the same weasel tool; only the tool id the
127
+ // factory reads tells them apart.
128
+ const made = factories.line?.({ x: 0, y: 0, width: 1, height: 1 }, {} as never);
129
+ expect(made?.data).toMatchObject({ kind: 'line', seen: seenFrom({ k: 1 }, ['k']) });
130
+ });
131
+
132
+ it('clears the committed buffer', () => {
133
+ const clears = mountThenAbandon();
134
+ for (const fn of frames) fn();
135
+ // One mock on the prototype serves every canvas; `this` tells them apart.
136
+ const open = vi.mocked(HTMLCanvasElement.prototype.getContext);
137
+ open.mockClear();
138
+ for (const clear of clears) clear({ width: 100, height: 50 }, 1);
139
+ // By identity: two blank canvases are deeply equal.
140
+ expect(open.mock.contexts).toHaveLength(1);
141
+ expect(open.mock.contexts[0]).toBe(committed);
142
+ });
143
+ });
@@ -1,18 +1,29 @@
1
1
  import {
2
2
  type InsertNodeFactory,
3
+ registerNodeShape,
3
4
  SceneCanvas,
4
5
  type SceneCanvasApi,
5
6
  useActiveToolContext,
7
+ useLatest,
6
8
  type View,
7
9
  WeaselProvider,
8
10
  } from '@weasel-js/core';
9
- import { type CSSProperties, useCallback, useContext, useEffect, useRef, useState } from 'react';
11
+ import {
12
+ type CSSProperties,
13
+ useCallback,
14
+ useContext,
15
+ useEffect,
16
+ useInsertionEffect,
17
+ useRef,
18
+ useState,
19
+ } from 'react';
10
20
  import { createPortal, flushSync } from 'react-dom';
11
21
  import { CameraWheelContext } from '../canvas/CameraWheelContext';
12
22
  import type { Rect } from '../surface/rect';
13
23
  import { useSurfaceCanvas, useSurfaceOptional, useTileId } from '../surface/useSurfaceTile';
14
24
  import { createMarkDrawOne } from './drawOne';
15
- import type { WorldRect } from './frac';
25
+ import { boundsOf, roundPoints, toShape, type WorldRect } from './frac';
26
+ import { POINT_MARK_SHAPE } from './paint';
16
27
  import { seenFrom } from './staleness';
17
28
  import type { MarkScene } from './store';
18
29
  import { ANNOTATION_WEASEL_TOOLS, annotationToolInfo } from './toolMap';
@@ -53,24 +64,32 @@ function ToolBridge({ toolId }: { toolId: string }) {
53
64
  return null;
54
65
  }
55
66
 
56
- /** Every vertex the insert carried, in fractions of the content box. */
57
- function pointsOf(
58
- extras: Record<string, unknown>,
59
- content: { w: number; h: number },
60
- ): readonly FracPoint[] | undefined {
61
- const toFrac = (p: { x: number; y: number }): FracPoint => ({
62
- x: content.w === 0 ? 0 : p.x / content.w,
63
- y: content.h === 0 ? 0 : p.y / content.h,
64
- });
65
- if (extras.kind === 'line') {
66
- return [toFrac(extras.a as FracPoint), toFrac(extras.b as FracPoint)];
67
- }
67
+ /** Every vertex the insert carried, in world units. */
68
+ function verticesOf(extras: Record<string, unknown>): readonly FracPoint[] | undefined {
69
+ if (extras.kind === 'line') return [extras.a as FracPoint, extras.b as FracPoint];
68
70
  if (extras.kind === 'pencil') {
69
- return (extras.samples as { x: number; y: number }[]).map(toFrac);
71
+ const samples = extras.samples as FracPoint[];
72
+ return samples.length > 0 ? samples : undefined;
70
73
  }
71
74
  return undefined;
72
75
  }
73
76
 
77
+ /** The pose and shape an insert's vertices make: the box that holds them, and
78
+ * the vertices stored against that very box. Undefined for an insert that
79
+ * carries no vertices, whose drag bounds are the whole geometry. */
80
+ export function insertedGeometry(
81
+ extras: Record<string, unknown>,
82
+ ): { pose: WorldRect; shape: readonly FracPoint[] } | undefined {
83
+ const vertices = verticesOf(extras);
84
+ if (!vertices) return undefined;
85
+ const box = boundsOf(vertices);
86
+ if (!box) return undefined;
87
+ return {
88
+ pose: { x: box.x, y: box.y, width: box.w, height: box.h },
89
+ shape: roundPoints(toShape(vertices, box)),
90
+ };
91
+ }
92
+
74
93
  /**
75
94
  * One target's drawing surface: a transparent box over the target's element
76
95
  * taking the input, and a `<SceneCanvas>` painting into the lab's shared
@@ -92,14 +111,13 @@ export function AnnotationOverlay({
92
111
  const [rect, setRect] = useState<Rect | null>(null);
93
112
  const [input, setInput] = useState<HTMLDivElement | null>(null);
94
113
  const sceneCanvas = useRef<SceneCanvasApi | null>(null);
114
+ useEffect(() => registerNodeShape(POINT_MARK_SHAPE, { priority: 'high' }), []);
95
115
 
96
116
  // Read in the insert factory, which is built once and must see the tool the
97
117
  // pane is holding now — `arrow` and `stroke` are indistinguishable from
98
118
  // `line` and `pencil` at the weasel end.
99
- const toolRef = useRef(activeToolId);
100
- toolRef.current = activeToolId;
101
- const configRef = useRef(config);
102
- configRef.current = config;
119
+ const toolRef = useLatest(activeToolId);
120
+ const configRef = useLatest(config);
103
121
 
104
122
  const id = target.id;
105
123
  // The surface's tile namespace is the whole lab's, and every trial of an
@@ -132,9 +150,13 @@ export function AnnotationOverlay({
132
150
  // renderer has opened creates the context without the stencil buffer marks
133
151
  // need, and every later request gets that context back.
134
152
  const painted = useRef(false);
135
- const canvasRef = useRef(canvas);
136
- if (canvasRef.current !== canvas) painted.current = false;
137
- canvasRef.current = canvas;
153
+ const canvasRef = useLatest(canvas);
154
+ // A new buffer has not been painted into, whatever the old one had. Reset
155
+ // with the commit that brings it, ahead of any paint that commit triggers.
156
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `canvas` is the signal, not a value this reads
157
+ useInsertionEffect(() => {
158
+ painted.current = false;
159
+ }, [canvas]);
138
160
  const unsubscribeFrame = useRef<(() => void) | null>(null);
139
161
  const attachSceneCanvas = useCallback((api: SceneCanvasApi | null) => {
140
162
  sceneCanvas.current = api;
@@ -156,7 +178,7 @@ export function AnnotationOverlay({
156
178
  gl.clearColor(0, 0, 0, 0);
157
179
  gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
158
180
  });
159
- }, [surface, tileId]);
181
+ }, [canvasRef, surface, tileId]);
160
182
 
161
183
  // The box is portalled out of whatever the picture sits in, so a wheel over
162
184
  // a mark would never reach the trial's camera without being handed to it.
@@ -200,14 +222,11 @@ export function AnnotationOverlay({
200
222
  const info = annotationToolInfo(toolRef.current);
201
223
  const kind: AnnotationKind | undefined = info?.kind;
202
224
  if (!kind) return null;
203
- const points = pointsOf(extras as unknown as Record<string, unknown>, target.content);
204
- const data: AnnotationData = {
205
- target: id,
206
- kind,
207
- ...(points ? { points } : {}),
208
- seen: seenFrom(configRef.current, target.positionDependsOn ?? []),
209
- };
210
- return { data };
225
+ const seen = seenFrom(configRef.current, target.positionDependsOn ?? []);
226
+ const geometry = insertedGeometry(extras as unknown as Record<string, unknown>);
227
+ if (!geometry) return { data: { target: id, kind, seen } satisfies AnnotationData };
228
+ const data: AnnotationData = { target: id, kind, shape: geometry.shape, seen };
229
+ return { data, pose: geometry.pose };
211
230
  };
212
231
  }
213
232
 
@@ -236,11 +255,11 @@ export function AnnotationOverlay({
236
255
  style={box}
237
256
  />
238
257
  {canvas && input ? (
239
- // One scope per pane: a shared <ActionsProvider> lets only the newest
240
- // canvas under it respond to input, and the rest go silently dead.
258
+ // Isolated so each pane keeps its own selection and tool.
241
259
  <WeaselProvider isolate>
242
260
  <ToolBridge toolId={annotationToolInfo(activeToolId)?.weaselTool ?? 'select'} />
243
261
  <SceneCanvas<AnnotationData, 'marks', WorldRect>
262
+ features={['pick', 'move', 'transform', 'edit']}
244
263
  ref={attachSceneCanvas}
245
264
  scene={scene}
246
265
  width={rect.w}
@@ -59,16 +59,6 @@
59
59
 
60
60
  .lk-mark-list__drop {
61
61
  margin-left: auto;
62
- background: none;
63
- border: 0;
64
- color: var(--wzl-fg-muted);
65
- cursor: pointer;
66
- }
67
-
68
- // Box and typography come from <Input>. This only says how the field shares
69
- // the row.
70
- .lk-mark-list__title {
71
- width: 100%;
72
62
  }
73
63
 
74
64
  // The export panel: a target to export, how, and at what resolution. Held in
@@ -15,6 +15,7 @@ import { TrialIdProvider } from '../state/context';
15
15
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
16
16
  import type { SurfaceClear, SurfaceHandle } from '../surface/useTiledSurface';
17
17
  import { useTiledSurface } from '../surface/useTiledSurface';
18
+ import { insertedGeometry } from './AnnotationOverlay';
18
19
  import { AnnotationTargets } from './AnnotationTargets';
19
20
  import { createAnnotationStore } from './store';
20
21
  import type { AnnotationsCapability, AnnotationTarget } from './types';
@@ -354,3 +355,33 @@ describe('two trials of one instrument', () => {
354
355
  ]);
355
356
  });
356
357
  });
358
+
359
+ describe('the geometry a drawn mark is inserted with', () => {
360
+ it("boxes a line's ends and stores them against that box", () => {
361
+ const got = insertedGeometry({ kind: 'line', a: { x: 10, y: 40 }, b: { x: 50, y: 20 } });
362
+ expect(got).toEqual({
363
+ pose: { x: 10, y: 20, width: 40, height: 20 },
364
+ shape: [
365
+ { x: 0, y: 1 },
366
+ { x: 1, y: 0 },
367
+ ],
368
+ });
369
+ });
370
+
371
+ it('boxes every freehand sample', () => {
372
+ const samples = [
373
+ { x: 0, y: 0 },
374
+ { x: 20, y: 10 },
375
+ { x: 40, y: 0 },
376
+ ];
377
+ expect(insertedGeometry({ kind: 'pencil', samples })?.shape).toEqual([
378
+ { x: 0, y: 0 },
379
+ { x: 0.5, y: 1 },
380
+ { x: 1, y: 0 },
381
+ ]);
382
+ });
383
+
384
+ it('leaves a box-shaped insert to its drag bounds', () => {
385
+ expect(insertedGeometry({ kind: 'rect' })).toBeUndefined();
386
+ });
387
+ });
@@ -1,4 +1,4 @@
1
- import { Input } from '../passthrough/weasel-ui';
1
+ import { CloseButton, Input } from '../passthrough/weasel-ui';
2
2
  import { useAnnotations } from './AnnotationsContext';
3
3
  import type { AnnotationMeaning } from './types';
4
4
 
@@ -35,17 +35,13 @@ export function MarkList({ meaning, config }: MarkListProps) {
35
35
  <span className="lk-mark-list__kind">{a.kind}</span>
36
36
  <span className="lk-mark-list__target">{a.target}</span>
37
37
  {stale ? <span className="lk-mark-list__stale">stale</span> : null}
38
- <button
39
- type="button"
38
+ <CloseButton
40
39
  className="lk-mark-list__drop"
41
- aria-label={`Remove ${a.kind} on ${a.target}`}
40
+ ariaLabel={`Remove ${a.kind} on ${a.target}`}
42
41
  onClick={() => marks.remove(a.id)}
43
- >
44
- ×
45
- </button>
42
+ />
46
43
  </div>
47
44
  <Input
48
- className="lk-mark-list__title"
49
45
  aria-label={`Title of ${a.kind} on ${a.target}`}
50
46
  value={a.title ?? ''}
51
47
  placeholder="Untitled"
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A raster capture against real WebGL2 and the real Inter files, with the
3
+ * default family registered lazily the way an app registers it: a text mark
4
+ * captured before anything has drawn text still carries its glyphs.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { registerFont } from '@weasel-js/font';
8
+ import { _resetFontRegistryForTests } from '@weasel-js/font/test-seams';
9
+ import { createAnnotationStore } from './store';
10
+ import metricsUrl from '../../../../assets/fonts/inter/inter.json?url';
11
+ import atlasUrl from '../../../../assets/fonts/inter/inter.png?url';
12
+
13
+ /** Pixels where the mark's red ink landed on the transparent export. */
14
+ async function inkPixels(blob: Blob): Promise<number> {
15
+ const bitmap = await createImageBitmap(blob);
16
+ const ctx = new OffscreenCanvas(bitmap.width, bitmap.height).getContext('2d')!;
17
+ ctx.drawImage(bitmap, 0, 0);
18
+ const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
19
+ let n = 0;
20
+ for (let i = 0; i < data.length; i += 4) {
21
+ if (data[i + 3] > 128 && data[i] > 150 && data[i + 1] < 120) n++;
22
+ }
23
+ return n;
24
+ }
25
+
26
+ beforeEach(() => _resetFontRegistryForTests());
27
+ afterEach(() => _resetFontRegistryForTests());
28
+
29
+ describe('a raster capture of a text mark', () => {
30
+ it('draws its glyphs when the font was registered lazily and never fetched', async () => {
31
+ void registerFont('sans-serif', {}, metricsUrl, atlasUrl, { lazy: true });
32
+ const store = createAnnotationStore({
33
+ targets: () => [{ id: 'bare', content: { w: 120, h: 40 } }],
34
+ });
35
+ store.add({ target: 'bare', kind: 'text', title: 'Hello', frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.5 } });
36
+
37
+ const result = await store.capture('bare', { format: 'png', scale: 2 });
38
+
39
+ expect(await inkPixels(result.blob)).toBeGreaterThan(40);
40
+ });
41
+ });
@@ -1,4 +1,4 @@
1
- import { asNodeId, renderSceneToPixels } from '@weasel-js/core';
1
+ import { asNodeId, renderSceneToPixels, warmRender } from '@weasel-js/core';
2
2
  import { serializeSvg } from '@weasel-js/svg';
3
3
  import { createMarkDrawOne, type MarkDrawOptions, resolveMarkStyle } from './drawOne';
4
4
  import type { MarkScene } from './store';
@@ -60,18 +60,20 @@ function embedRasterBase(
60
60
  }
61
61
 
62
62
  /** Every mark on a scene, as SVG nodes, styled the way the pane styles them. */
63
- function markNodesOf(scene: MarkScene, draw: MarkDrawOptions) {
63
+ function markNodesOf(scene: MarkScene, draw: MarkDrawOptions, scale: number) {
64
64
  return [...scene.renderOrder()].flatMap((id) => {
65
65
  const node = scene.get(asNodeId(String(id)));
66
66
  if (!node) return [];
67
67
  return markSvgNodes(
68
68
  { pose: node.pose, data: node.data as AnnotationData },
69
- draw.content,
70
69
  resolveMarkStyle(node.data as AnnotationData, draw),
70
+ { x: scale, y: scale },
71
71
  );
72
72
  });
73
73
  }
74
74
 
75
+ /** Inputs to `composeCaptureSvg`. `scale` is output pixels per content-box
76
+ * unit; `onWarn` receives non-fatal export warnings. */
75
77
  export interface ComposeSvgArgs {
76
78
  base?: CaptureSource;
77
79
  scene: MarkScene;
@@ -86,7 +88,10 @@ export interface ComposeSvgArgs {
86
88
  * The outer `viewBox` is the content box, so marks — which are already in it —
87
89
  * need no transform, and `width`/`height` carry the export scale. Both halves
88
90
  * nest as child `<svg>` elements, which each establish their own viewport and
89
- * so keep their own `viewBox` maths out of this function.
91
+ * so keep their own `viewBox` math out of this function.
92
+ *
93
+ * Synchronous with no `warmSvg`: marks export nothing that loads on demand,
94
+ * which `svgNodes.test.ts` pins.
90
95
  */
91
96
  export function composeCaptureSvg(args: ComposeSvgArgs): string {
92
97
  const { base, scene, draw, scale, onWarn } = args;
@@ -99,7 +104,7 @@ export function composeCaptureSvg(args: ComposeSvgArgs): string {
99
104
  : embedRasterBase(base, w, h, onWarn);
100
105
  // No width/height on the marks document: a nested <svg> without them fills
101
106
  // the outer viewport exactly, which is the alignment this relies on.
102
- const marksXml = serializeSvg(markNodesOf(scene, draw), {
107
+ const marksXml = serializeSvg(markNodesOf(scene, draw, scale), {
103
108
  viewBox: { x: 0, y: 0, width: w, height: h },
104
109
  onWarn,
105
110
  });
@@ -172,12 +177,14 @@ async function stackRaster(
172
177
  if (base?.kind === 'image') ctx.drawImage(await loadImage(base.src), 0, 0, width, height);
173
178
  else if (base?.kind === 'canvas') ctx.drawImage(base.canvas, 0, 0, width, height);
174
179
 
175
- const raster = renderSceneToPixels({
180
+ const render = {
176
181
  scene,
177
182
  sourceRect: { x: 0, y: 0, width: draw.content.w, height: draw.content.h },
178
183
  scale: { x: scale, y: scale },
179
184
  drawOne: createMarkDrawOne(draw),
180
- });
185
+ };
186
+ await warmRender({ render });
187
+ const raster = renderSceneToPixels(render);
181
188
  // Through a second canvas rather than `putImageData` on this one, which
182
189
  // replaces pixels instead of compositing and would erase the base.
183
190
  const marks = context2d(raster.width, raster.height);
@@ -30,8 +30,8 @@ export function createMarkDrawOne(
30
30
  pose: WorldRect,
31
31
  view: View,
32
32
  ) => DrawCommand[] {
33
- return (node, pose) =>
34
- markCommands({ pose, data: node.data }, opts.content, resolveMarkStyle(node.data, opts));
33
+ return (node, pose, view) =>
34
+ markCommands({ pose, data: node.data }, resolveMarkStyle(node.data, opts), view.scale);
35
35
  }
36
36
 
37
37
  /** How a mark looks, as opposed to where it is. Separate from the callback
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
2
+ import {
3
+ boundsOf,
4
+ fracContains,
5
+ fracEncloses,
6
+ fracToWorld,
7
+ fromShape,
8
+ roundFrac,
9
+ toShape,
10
+ worldToFrac,
11
+ } from './frac';
3
12
  import type { FracRect } from './types';
4
13
 
5
14
  const CONTENT = { w: 256, h: 170 };
@@ -64,3 +73,37 @@ describe('fraction hit geometry', () => {
64
73
  expect(fracEncloses({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
65
74
  });
66
75
  });
76
+
77
+ describe('a shape', () => {
78
+ const pts = [
79
+ { x: 0.1, y: 0.6 },
80
+ { x: 0.6, y: 0.2 },
81
+ ];
82
+
83
+ it('has no bounds without points', () => {
84
+ expect(boundsOf([])).toBeNull();
85
+ });
86
+
87
+ it('round-trips through the box that bounds it', () => {
88
+ const box = boundsOf(pts);
89
+ if (!box) throw new Error('no bounds');
90
+ const back = fromShape(toShape(pts, box), box);
91
+ back.forEach((p, i) => {
92
+ expect(p.x).toBeCloseTo(pts[i]?.x ?? Number.NaN, 9);
93
+ expect(p.y).toBeCloseTo(pts[i]?.y ?? Number.NaN, 9);
94
+ });
95
+ });
96
+
97
+ it('puts every point on the edge of an axis the box has no extent along', () => {
98
+ const flat = [
99
+ { x: 0.1, y: 0.5 },
100
+ { x: 0.4, y: 0.5 },
101
+ ];
102
+ const box = boundsOf(flat);
103
+ if (!box) throw new Error('no bounds');
104
+ expect(toShape(flat, box)).toEqual([
105
+ { x: 0, y: 0 },
106
+ { x: 1, y: 0 },
107
+ ]);
108
+ });
109
+ });