@weasel-js/labkit 1.3.0 → 1.4.0-pre.0

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 (210) hide show
  1. package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
  2. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  3. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  4. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  5. package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
  6. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  7. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  8. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  9. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  10. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  11. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  12. package/dist/canvas/index.d.ts +43 -25
  13. package/dist/canvas/index.js +5 -4
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +6 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
  27. package/dist/chunk-6JMU56CU.js.map +1 -0
  28. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  29. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  30. package/dist/chunk-CTRKTLYZ.js +33 -0
  31. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  32. package/dist/chunk-EQIB2PGC.js +458 -0
  33. package/dist/chunk-EQIB2PGC.js.map +1 -0
  34. package/dist/chunk-HEORBLVT.js +7128 -0
  35. package/dist/chunk-HEORBLVT.js.map +1 -0
  36. package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
  37. package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  43. package/dist/chunk-XJ6N32QP.js.map +1 -0
  44. package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
  45. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  46. package/dist/controls/index.d.ts +4 -28
  47. package/dist/controls/index.js +3 -3
  48. package/dist/dragdrop/index.d.ts +9 -5
  49. package/dist/dragdrop/index.js +3 -3
  50. package/dist/index.d.ts +518 -190
  51. package/dist/index.js +1753 -75
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +6 -5
  54. package/dist/layers/index.js +3 -3
  55. package/dist/loupe/index.d.ts +187 -0
  56. package/dist/loupe/index.js +7 -0
  57. package/dist/loupe/index.js.map +1 -0
  58. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  59. package/dist/passthrough/weasel-canvas.js +1 -1
  60. package/dist/passthrough/weasel-ui.d.ts +138 -91
  61. package/dist/passthrough/weasel-ui.js +2 -2
  62. package/dist/primitives/index.d.ts +3 -1
  63. package/dist/primitives/index.js +5 -5
  64. package/dist/state/index.d.ts +3 -3
  65. package/dist/state/index.js +2 -2
  66. package/dist/styles.css +229 -1
  67. package/dist/surface/index.d.ts +18 -2
  68. package/dist/surface/index.js +2 -2
  69. package/dist/ui/layers/index.js +2 -2
  70. package/dist/undo/index.d.ts +5 -4
  71. package/package.json +14 -7
  72. package/src/annotations/AnnotationOverlay.tsx +205 -0
  73. package/src/annotations/AnnotationTargets.tsx +37 -0
  74. package/src/annotations/Annotations.less +129 -0
  75. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  76. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  77. package/src/annotations/AnnotationsContext.ts +49 -0
  78. package/src/annotations/ExportMenu.test.tsx +86 -0
  79. package/src/annotations/ExportMenu.tsx +182 -0
  80. package/src/annotations/MarkList.tsx +75 -0
  81. package/src/annotations/capture.test.ts +140 -0
  82. package/src/annotations/capture.ts +237 -0
  83. package/src/annotations/drawOne.test.ts +56 -0
  84. package/src/annotations/drawOne.ts +47 -0
  85. package/src/annotations/frac.test.ts +59 -0
  86. package/src/annotations/frac.ts +66 -0
  87. package/src/annotations/history.test.ts +82 -0
  88. package/src/annotations/history.ts +118 -0
  89. package/src/annotations/index.ts +37 -0
  90. package/src/annotations/paint.test.ts +125 -0
  91. package/src/annotations/paint.ts +130 -0
  92. package/src/annotations/staleness.test.ts +56 -0
  93. package/src/annotations/staleness.ts +41 -0
  94. package/src/annotations/store.test.ts +175 -0
  95. package/src/annotations/store.ts +317 -0
  96. package/src/annotations/svgNodes.test.ts +77 -0
  97. package/src/annotations/svgNodes.ts +74 -0
  98. package/src/annotations/toolMap.test.ts +36 -0
  99. package/src/annotations/toolMap.ts +54 -0
  100. package/src/annotations/types.ts +222 -0
  101. package/src/annotations/view.test.ts +37 -0
  102. package/src/annotations/view.ts +40 -0
  103. package/src/canvas/AGENTS.md +55 -5
  104. package/src/canvas/CanvasStack.test.tsx +27 -1
  105. package/src/canvas/CanvasStack.tsx +29 -4
  106. package/src/canvas/CanvasStackContext.ts +23 -4
  107. package/src/canvas/camera.test.ts +78 -0
  108. package/src/canvas/camera.ts +62 -0
  109. package/src/canvas/canvasCoords.test.ts +26 -0
  110. package/src/canvas/canvasCoords.ts +20 -7
  111. package/src/canvas/index.ts +5 -1
  112. package/src/canvas/useLayerScheduler.ts +7 -3
  113. package/src/canvas/usePanZoom.test.ts +36 -5
  114. package/src/canvas/usePanZoom.ts +20 -15
  115. package/src/canvas/worldSpec.test.ts +63 -0
  116. package/src/canvas/worldSpec.ts +48 -0
  117. package/src/chrome/builtins.test.ts +31 -1
  118. package/src/chrome/builtins.tsx +63 -19
  119. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  120. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  121. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  122. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  123. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  124. package/src/chrome/regions/regions.test.tsx +6 -1
  125. package/src/chrome/types.ts +20 -0
  126. package/src/controls/ControlPanel.test.tsx +38 -0
  127. package/src/controls/ControlPanel.tsx +50 -3
  128. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  129. package/src/dragdrop/dragDrop.test.tsx +29 -2
  130. package/src/index.test.ts +22 -0
  131. package/src/index.ts +87 -2
  132. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  133. package/src/instrument/types.ts +24 -1
  134. package/src/lab/Lab.surface.test.tsx +162 -0
  135. package/src/lab/Lab.tsx +102 -17
  136. package/src/lab/Lab.undock.test.tsx +85 -0
  137. package/src/lab/LabFullChrome.stories.tsx +20 -0
  138. package/src/lab/LabShell.less +11 -0
  139. package/src/lab/Workspace.less +73 -0
  140. package/src/lab/Workspace.surface.test.tsx +2 -0
  141. package/src/lab/Workspace.tsx +97 -8
  142. package/src/lab/index.ts +1 -1
  143. package/src/lab/panelHost.ts +37 -0
  144. package/src/loupe/AGENTS.md +49 -0
  145. package/src/loupe/CanvasLoupe.tsx +83 -0
  146. package/src/loupe/DomLoupe.tsx +62 -0
  147. package/src/loupe/Loupe.less +38 -0
  148. package/src/loupe/LoupeBubble.tsx +32 -0
  149. package/src/loupe/TrialLoupe.tsx +93 -0
  150. package/src/loupe/canvasLens.test.ts +205 -0
  151. package/src/loupe/canvasLens.ts +141 -0
  152. package/src/loupe/index.ts +19 -0
  153. package/src/loupe/types.test.ts +29 -0
  154. package/src/loupe/types.ts +90 -0
  155. package/src/loupe/useHostSize.ts +30 -0
  156. package/src/loupe/useLoupe.test.tsx +197 -0
  157. package/src/loupe/useLoupe.ts +189 -0
  158. package/src/passthrough/weasel-ui.ts +3 -0
  159. package/src/primitives/FloatingPanel.test.tsx +76 -1
  160. package/src/primitives/FloatingPanel.tsx +19 -3
  161. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  162. package/src/primitives/Sidebar.less +18 -0
  163. package/src/primitives/Toolbar.less +8 -0
  164. package/src/primitives/Toolbar.tsx +5 -0
  165. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  166. package/src/primitives/useRovingTabIndex.ts +26 -17
  167. package/src/state/document.test.ts +23 -0
  168. package/src/state/document.ts +14 -2
  169. package/src/state/index.ts +2 -0
  170. package/src/state/store.test.ts +24 -0
  171. package/src/state/store.ts +35 -1
  172. package/src/state/types.ts +11 -0
  173. package/src/state/undock.test.ts +40 -0
  174. package/src/state/undock.ts +39 -0
  175. package/src/styles.less +2 -0
  176. package/src/surface/SurfaceContext.ts +4 -0
  177. package/src/surface/index.ts +8 -3
  178. package/src/surface/useSurfaceTile.test.tsx +2 -0
  179. package/src/surface/useSurfaceTile.ts +7 -1
  180. package/src/surface/useTiledSurface.test.tsx +58 -0
  181. package/src/surface/useTiledSurface.ts +38 -3
  182. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  183. package/src/trial/Trial.annotations.test.tsx +121 -0
  184. package/src/trial/Trial.canvas.test.tsx +76 -1
  185. package/src/trial/Trial.less +19 -0
  186. package/src/trial/Trial.loupe.test.tsx +128 -0
  187. package/src/trial/Trial.stories.tsx +1 -0
  188. package/src/trial/Trial.test.tsx +12 -0
  189. package/src/trial/Trial.trialId.test.tsx +36 -0
  190. package/src/trial/Trial.tsx +215 -40
  191. package/src/trial/TrialChrome.tsx +35 -0
  192. package/src/trial/UndockedSections.tsx +65 -0
  193. package/src/trial/trialOps.test.ts +21 -0
  194. package/src/trial/trialOps.ts +5 -3
  195. package/dist/_dts/types-BttTIent.d.ts +0 -314
  196. package/dist/chunk-3XXPU73K.js.map +0 -1
  197. package/dist/chunk-4TMMVIDM.js.map +0 -1
  198. package/dist/chunk-6X5RBC6G.js +0 -366
  199. package/dist/chunk-6X5RBC6G.js.map +0 -1
  200. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  201. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  202. package/dist/chunk-JDROYQM3.js.map +0 -1
  203. package/dist/chunk-LGKRIUSW.js.map +0 -1
  204. package/dist/chunk-QCICIHEO.js.map +0 -1
  205. package/dist/chunk-QRYPXSGP.js.map +0 -1
  206. package/dist/chunk-RGT4EJIB.js +0 -6961
  207. package/dist/chunk-RGT4EJIB.js.map +0 -1
  208. package/dist/chunk-T66HU3HX.js +0 -17
  209. package/dist/chunk-T66HU3HX.js.map +0 -1
  210. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -0,0 +1,37 @@
1
+ export type { CaptureArgs, CapturePlan, ComposeSvgArgs } from './capture';
2
+ export { capturePlan, captureTarget, composeCaptureSvg } from './capture';
3
+ export type { MarkDrawOptions } from './drawOne';
4
+ export { createMarkDrawOne, resolveMarkStyle } from './drawOne';
5
+ export { ExportMenu } from './ExportMenu';
6
+ export type { WorldRect } from './frac';
7
+ export { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
8
+ export type { HistoryScene } from './history';
9
+ export { MarkHistory } from './history';
10
+ export type { MarkListProps } from './MarkList';
11
+ export { MarkList } from './MarkList';
12
+ export type { MarkStyle, PaintableMark } from './paint';
13
+ export { markCommands } from './paint';
14
+ export { isStale, seenFrom } from './staleness';
15
+ export type { AnnotationStoreOptions, CaptureDeps, MarkScene } from './store';
16
+ export { annotationsFromJSON, createAnnotationScene, createAnnotationStore } from './store';
17
+ export { markSvgNodes } from './svgNodes';
18
+ export type {
19
+ Annotation,
20
+ AnnotationData,
21
+ AnnotationInit,
22
+ AnnotationKind,
23
+ AnnotationMeaning,
24
+ AnnotationPatch,
25
+ AnnotationQuery,
26
+ AnnotationStatus,
27
+ AnnotationsApi,
28
+ AnnotationsCapability,
29
+ AnnotationTarget,
30
+ AnnotationTargetInfo,
31
+ CaptureOptions,
32
+ CaptureResult,
33
+ CaptureSource,
34
+ FracPoint,
35
+ FracRect,
36
+ SerializedAnnotations,
37
+ } from './types';
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markCommands } from './paint';
3
+ import type { AnnotationData, AnnotationKind, FracPoint } from './types';
4
+
5
+ const CONTENT = { w: 200, h: 100 };
6
+ const POSE = { x: 20, y: 10, width: 60, height: 40 };
7
+
8
+ function mark(kind: AnnotationKind, extra: Partial<AnnotationData> = {}) {
9
+ return { pose: POSE, data: { target: 't', kind, ...extra } as AnnotationData };
10
+ }
11
+
12
+ /** Every point of the first command's path, in world units. */
13
+ function anchors(cmds: ReturnType<typeof markCommands>): number[] {
14
+ const cmd = cmds[0];
15
+ if (cmd?.kind !== 'path') throw new Error('expected a path command');
16
+ const p = cmd.path as { kind: string; coords?: Float32Array };
17
+ return p.coords ? [...p.coords] : [];
18
+ }
19
+
20
+ const ENDS: readonly FracPoint[] = [
21
+ { x: 0.1, y: 0.2 },
22
+ { x: 0.6, y: 0.9 },
23
+ ];
24
+
25
+ describe('markCommands', () => {
26
+ it('draws a rect at the pose', () => {
27
+ const [cmd] = markCommands(mark('rect'), CONTENT);
28
+ expect(cmd).toMatchObject({ kind: 'path', path: { kind: 'rect', ...POSE } });
29
+ expect((cmd as { fill?: unknown }).fill).toBeUndefined();
30
+ });
31
+
32
+ it('draws an ellipse inscribed in the pose', () => {
33
+ const pts = anchors(markCommands(mark('ellipse'), CONTENT));
34
+ const xs = pts.filter((_, i) => i % 2 === 0);
35
+ const ys = pts.filter((_, i) => i % 2 === 1);
36
+ expect(Math.min(...xs)).toBeCloseTo(POSE.x, 4);
37
+ expect(Math.max(...xs)).toBeCloseTo(POSE.x + POSE.width, 4);
38
+ expect(Math.min(...ys)).toBeCloseTo(POSE.y, 4);
39
+ expect(Math.max(...ys)).toBeCloseTo(POSE.y + POSE.height, 4);
40
+ });
41
+
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
+ ]);
49
+ });
50
+
51
+ it('makes an arrow the same line, plus the head as its own command', () => {
52
+ const cmds = markCommands(mark('arrow', { points: ENDS }), CONTENT);
53
+ const stroke = (cmds[0] as { stroke?: { markerEnd?: unknown } }).stroke;
54
+ 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);
68
+ });
69
+
70
+ it('threads a freehand stroke through every point', () => {
71
+ const pts: FracPoint[] = [
72
+ { x: 0, y: 0 },
73
+ { x: 0.5, y: 0.5 },
74
+ { x: 1, y: 0 },
75
+ ];
76
+ expect(anchors(markCommands(mark('stroke', { points: pts }), CONTENT))).toEqual([
77
+ 0, 0, 100, 50, 200, 0,
78
+ ]);
79
+ });
80
+
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
83
+ // something at the right place, not vanish.
84
+ expect(anchors(markCommands(mark('line'), CONTENT))).toEqual([
85
+ POSE.x,
86
+ POSE.y,
87
+ POSE.x + POSE.width,
88
+ POSE.y + POSE.height,
89
+ ]);
90
+ });
91
+
92
+ it('draws text at the pose, carrying the title', () => {
93
+ const [cmd] = markCommands(mark('text', { title: 'missing edge' }), CONTENT);
94
+ expect(cmd).toMatchObject({ kind: 'text', x: POSE.x, y: POSE.y });
95
+ const runs = (cmd as { runs: { text: string }[] }).runs;
96
+ expect(runs.map((r) => r.text).join('')).toBe('missing edge');
97
+ });
98
+
99
+ it('draws an untitled text mark as nothing rather than an empty run', () => {
100
+ expect(markCommands(mark('text'), CONTENT)).toEqual([]);
101
+ });
102
+ });
103
+
104
+ describe('markCommands styling', () => {
105
+ it('takes a status colour over the default', () => {
106
+ const [cmd] = markCommands(mark('rect'), CONTENT, { color: '#30a46c' });
107
+ expect((cmd as { stroke?: { paint?: { color?: string } } }).stroke?.paint?.color).toBe(
108
+ '#30a46c',
109
+ );
110
+ });
111
+
112
+ it('dashes a stale mark rather than hiding it', () => {
113
+ // A stale mark still describes something; dropping it would lose it.
114
+ const [fresh] = markCommands(mark('rect'), CONTENT);
115
+ const [stale] = markCommands(mark('rect'), CONTENT, { stale: true });
116
+ expect((fresh as { stroke?: { dash?: number[] } }).stroke?.dash).toBeUndefined();
117
+ expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
118
+ });
119
+
120
+ it("colours a text mark's glyphs too, not only the outlines", () => {
121
+ const [cmd] = markCommands(mark('text', { title: 'x' }), CONTENT, { color: '#30a46c' });
122
+ const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
123
+ expect(runs[0]?.fill?.color).toBe('#30a46c');
124
+ });
125
+ });
@@ -0,0 +1,130 @@
1
+ import type { DrawCommand, Path, Stroke } from '@weasel-js/core';
2
+ import {
3
+ ellipsePath,
4
+ linePath,
5
+ markerDrawCommands,
6
+ PathBuilder,
7
+ rectPath,
8
+ textCommand,
9
+ } from '@weasel-js/core';
10
+ import type { WorldRect } from './frac';
11
+ import type { AnnotationData, FracPoint } from './types';
12
+
13
+ /** One loud color, not a themed one: a mark sits over the instrument's own
14
+ * picture and has to be legible against whatever that picture is. */
15
+ 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
+ const MARK_WIDTH = 2;
19
+ const TEXT_SIZE = 14;
20
+
21
+ /** The subset of a mark's scene node this needs: where it is, and what it is. */
22
+ export interface PaintableMark {
23
+ pose: WorldRect;
24
+ data: AnnotationData;
25
+ }
26
+
27
+ /** How a mark is drawn, as opposed to where. Resolved by the overlay from the
28
+ * instrument's vocabulary and the mark's own staleness. */
29
+ export interface MarkStyle {
30
+ /** The status's colour, or the default. */
31
+ color?: string;
32
+ /** A mark whose stored position no longer describes the picture. Drawn
33
+ * dashed rather than hidden: it still describes *something*, and dropping
34
+ * it would lose it. */
35
+ stale?: boolean;
36
+ }
37
+
38
+ const toWorld = (p: FracPoint, content: { w: number; h: number }) => ({
39
+ x: p.x * content.w,
40
+ y: p.y * content.h,
41
+ });
42
+
43
+ /** A mark's stored vertices in world units, or the pose's diagonal — a stored
44
+ * mark whose `points` did not survive still has to draw somewhere. */
45
+ function vertices(m: PaintableMark, content: { w: number; h: number }): { x: number; y: number }[] {
46
+ const stored = m.data.points;
47
+ if (stored && stored.length >= 2) return stored.map((p) => toWorld(p, content));
48
+ const { x, y, width, height } = m.pose;
49
+ return [
50
+ { x, y },
51
+ { x: x + width, y: y + height },
52
+ ];
53
+ }
54
+
55
+ function polyline(points: { x: number; y: number }[]): Path {
56
+ const b = new PathBuilder();
57
+ const first = points[0];
58
+ if (!first) return rectPath(0, 0, 0, 0);
59
+ b.moveTo(first.x, first.y);
60
+ for (const p of points.slice(1)) b.lineTo(p.x, p.y);
61
+ return b.build();
62
+ }
63
+
64
+ /**
65
+ * What one mark draws, in its target's world.
66
+ *
67
+ * Pure: a node and the target's content box in, draw commands out. Geometry
68
+ * that a bounding box cannot describe — a line's ends, a stroke's path — comes
69
+ * from `data.points`, which is in fractions like the bounds.
70
+ */
71
+ export function markCommands(
72
+ m: PaintableMark,
73
+ content: { w: number; h: number },
74
+ style: MarkStyle = {},
75
+ ): DrawCommand[] {
76
+ const color = style.color ?? MARK_COLOR;
77
+ const stroke: Stroke = {
78
+ paint: { color },
79
+ width: MARK_WIDTH,
80
+ cap: 'round',
81
+ join: 'round',
82
+ ...(style.stale ? { dash: [6, 4] } : {}),
83
+ };
84
+
85
+ switch (m.data.kind) {
86
+ case 'rect':
87
+ return [
88
+ { kind: 'path', path: rectPath(m.pose.x, m.pose.y, m.pose.width, m.pose.height), stroke },
89
+ ];
90
+ case 'ellipse':
91
+ return [{ kind: 'path', path: ellipsePath(m.pose), stroke }];
92
+ case 'line': {
93
+ const [a, b] = vertices(m, content);
94
+ return [{ kind: 'path', path: linePath(a, b), stroke }];
95
+ }
96
+ case 'arrow': {
97
+ const [a, b] = vertices(m, content);
98
+ // The spec's arrow: a line carrying an end marker, not its own geometry.
99
+ // Markers are separate commands — a stroke's `markerEnd` is inert unless
100
+ // something turns it into geometry, and only the kit's own node painter
101
+ // does that, for the nodes it owns.
102
+ const marked: Stroke = { ...stroke, markerEnd: 'arrow' };
103
+ const path = linePath(a, b);
104
+ return [
105
+ { kind: 'path', path, stroke: marked },
106
+ ...markerDrawCommands(path, marked, MARK_WIDTH, undefined),
107
+ ];
108
+ }
109
+ case 'stroke':
110
+ return [{ kind: 'path', path: polyline(vertices(m, content)), stroke }];
111
+ case 'text': {
112
+ const text = m.data.title;
113
+ if (!text) return [];
114
+ return [
115
+ textCommand(
116
+ m.pose.x,
117
+ m.pose.y,
118
+ text,
119
+ { fontSize: TEXT_SIZE },
120
+ undefined,
121
+ undefined,
122
+ undefined,
123
+ {
124
+ fill: { color },
125
+ },
126
+ ),
127
+ ];
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isStale, seenFrom } from './staleness';
3
+
4
+ const KEYS = ['angle', 'shading', 'shade_style'] as const;
5
+ const CONFIG = { angle: 'iso', shading: 'outline', shade_style: 'flat3', render_px: 512 };
6
+
7
+ describe('seenFrom', () => {
8
+ it('snapshots only the declared keys', () => {
9
+ // render_px is deliberately not declared: the mark is fractional, so
10
+ // resolution does not move it.
11
+ expect(seenFrom(CONFIG, KEYS)).toEqual({
12
+ angle: 'iso',
13
+ shading: 'outline',
14
+ shade_style: 'flat3',
15
+ });
16
+ });
17
+
18
+ it('omits a declared key the config does not carry', () => {
19
+ expect(seenFrom({ angle: 'top' }, KEYS)).toEqual({ angle: 'top' });
20
+ });
21
+
22
+ it('is empty when nothing is declared', () => {
23
+ expect(seenFrom(CONFIG, [])).toEqual({});
24
+ });
25
+ });
26
+
27
+ describe('isStale', () => {
28
+ it('is not stale against the config it was taken from', () => {
29
+ expect(isStale(seenFrom(CONFIG, KEYS), CONFIG, KEYS)).toBe(false);
30
+ });
31
+
32
+ it('is stale when a declared key changed', () => {
33
+ const seen = seenFrom(CONFIG, KEYS);
34
+ expect(isStale(seen, { ...CONFIG, angle: 'top' }, KEYS)).toBe(true);
35
+ });
36
+
37
+ it('ignores an undeclared key changing', () => {
38
+ const seen = seenFrom(CONFIG, KEYS);
39
+ expect(isStale(seen, { ...CONFIG, render_px: 1024 }, KEYS)).toBe(false);
40
+ });
41
+
42
+ it('ignores a key the stored snapshot never had', () => {
43
+ // A mark filed before the target declared a new dependency must not go
44
+ // stale retroactively — nothing can know what that key was at the time.
45
+ const old = { angle: 'iso' };
46
+ expect(isStale(old, CONFIG, KEYS)).toBe(false);
47
+ });
48
+
49
+ it('is not stale with no snapshot at all', () => {
50
+ expect(isStale(undefined, CONFIG, KEYS)).toBe(false);
51
+ });
52
+
53
+ it('is never stale when the target declares no dependencies', () => {
54
+ expect(isStale(seenFrom(CONFIG, KEYS), { angle: 'top' }, [])).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Whether a stored mark still describes the picture the current config
3
+ * produces. A target names the config keys its positions depend on; labkit
4
+ * snapshots and compares them without knowing what any of them mean.
5
+ */
6
+
7
+ /** The values of `keys` in `config`, for storing beside a new mark. Keys the
8
+ * config does not carry are omitted rather than stored as undefined, so the
9
+ * snapshot says what was known rather than what was asked. */
10
+ export function seenFrom(config: unknown, keys: readonly string[]): Record<string, unknown> {
11
+ const out: Record<string, unknown> = {};
12
+ if (config === null || typeof config !== 'object') return out;
13
+ const src = config as Record<string, unknown>;
14
+ for (const k of keys) {
15
+ if (k in src) out[k] = src[k];
16
+ }
17
+ return out;
18
+ }
19
+
20
+ /**
21
+ * Whether the picture moved under the mark.
22
+ *
23
+ * Only keys present in `seen` are compared. A mark filed before its target
24
+ * declared a new dependency must not go stale retroactively: nothing can know
25
+ * what that key was at the time, and guessing would grey out every old mark
26
+ * the first time a target grows a key.
27
+ */
28
+ export function isStale(
29
+ seen: Readonly<Record<string, unknown>> | undefined,
30
+ config: unknown,
31
+ keys: readonly string[],
32
+ ): boolean {
33
+ if (!seen || keys.length === 0) return false;
34
+ if (config === null || typeof config !== 'object') return false;
35
+ const src = config as Record<string, unknown>;
36
+ for (const k of keys) {
37
+ if (!(k in seen)) continue;
38
+ if (!Object.is(seen[k], src[k])) return true;
39
+ }
40
+ return false;
41
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { annotationsFromJSON, createAnnotationStore } from './store';
3
+ import type { AnnotationInit, AnnotationTargetInfo } from './types';
4
+
5
+ const TARGETS: AnnotationTargetInfo[] = [
6
+ { id: 'naive', content: { w: 256, h: 170 }, positionDependsOn: ['angle', 'shading'] },
7
+ { id: 'occt', content: { w: 256, h: 170 } },
8
+ ];
9
+
10
+ function makeStore() {
11
+ return createAnnotationStore({ targets: () => TARGETS });
12
+ }
13
+
14
+ const RING: AnnotationInit = {
15
+ target: 'naive',
16
+ kind: 'rect',
17
+ frac: { x: 0.3322, y: 0.3581, w: 0.02, h: 0.0861 },
18
+ title: 'spurious ring at r=12.80',
19
+ status: 'open',
20
+ tags: ['geometry'],
21
+ meta: { engines: ['naive'] },
22
+ };
23
+
24
+ describe('the annotation store', () => {
25
+ it('round-trips every field through add and get', () => {
26
+ const store = makeStore();
27
+ const id = store.add(RING, { angle: 'iso', shading: 'outline' });
28
+ const got = store.get(id);
29
+ expect(got).toMatchObject({
30
+ id,
31
+ target: 'naive',
32
+ kind: 'rect',
33
+ frac: RING.frac,
34
+ title: RING.title,
35
+ status: 'open',
36
+ tags: ['geometry'],
37
+ meta: { engines: ['naive'] },
38
+ seen: { angle: 'iso', shading: 'outline' },
39
+ });
40
+ });
41
+
42
+ it('dates a mark only by the keys its target declared', () => {
43
+ const store = makeStore();
44
+ const onOcct = store.add({ ...RING, target: 'occt' }, { angle: 'iso' });
45
+ // 'occt' declares no dependencies, so nothing is worth snapshotting.
46
+ expect(store.get(onOcct)?.seen).toEqual({});
47
+ });
48
+
49
+ it('filters by target, kind, status, tags and a predicate', () => {
50
+ const store = makeStore();
51
+ store.add(RING);
52
+ store.add({ ...RING, target: 'occt', status: 'fixed', tags: ['shading'] });
53
+ store.add({ ...RING, kind: 'line', tags: ['geometry', 'edge'] });
54
+
55
+ expect(store.query({ target: 'naive' })).toHaveLength(2);
56
+ expect(store.query({ kind: 'line' })).toHaveLength(1);
57
+ expect(store.query({ status: 'fixed' })).toHaveLength(1);
58
+ expect(store.query({ tags: ['geometry'] })).toHaveLength(2);
59
+ // Every listed tag must be present, not just one.
60
+ expect(store.query({ tags: ['geometry', 'edge'] })).toHaveLength(1);
61
+ expect(store.query({ where: (a) => a.title === RING.title })).toHaveLength(3);
62
+ expect(store.query()).toHaveLength(3);
63
+ });
64
+
65
+ it('hit-tests a point, and widens the hit by the tolerance', () => {
66
+ const store = makeStore();
67
+ const id = store.add({ ...RING, frac: { x: 0.2, y: 0.2, w: 0.2, h: 0.2 } });
68
+
69
+ expect(store.hitTest('naive', { x: 0.3, y: 0.3 }).map((a) => a.id)).toEqual([id]);
70
+ expect(store.hitTest('naive', { x: 0.15, y: 0.3 })).toHaveLength(0);
71
+ expect(store.hitTest('naive', { x: 0.15, y: 0.3 }, 0.06)).toHaveLength(1);
72
+ // A target's marks are its own.
73
+ expect(store.hitTest('occt', { x: 0.3, y: 0.3 })).toHaveLength(0);
74
+ });
75
+
76
+ it("keeps each target in its own scene, so a pane never sees a neighbour's marks", () => {
77
+ const store = makeStore();
78
+ store.add(RING);
79
+ store.add({ ...RING, target: 'occt' });
80
+ // Not a filter over one scene: the scene a pane is handed holds only its
81
+ // own marks, because its hit-test and marquee walk all of what they get.
82
+ expect(store.sceneFor('naive').renderOrder()).toHaveLength(1);
83
+ expect(store.sceneFor('occt').renderOrder()).toHaveLength(1);
84
+ expect(store.query({ target: 'naive' }).map((a) => a.target)).toEqual(['naive']);
85
+ });
86
+
87
+ it('takes what a box encloses, not what it grazes', () => {
88
+ const store = makeStore();
89
+ // The box is (0.2,0.2)-(0.6,0.6). `straddling` overlaps it but hangs out
90
+ // past the far corner — the case that separates enclosure from mere
91
+ // overlap. Two marks that are both wholly outside would not.
92
+ const inside = store.add({ ...RING, frac: { x: 0.3, y: 0.3, w: 0.1, h: 0.1 } });
93
+ store.add({ ...RING, frac: { x: 0.5, y: 0.5, w: 0.3, h: 0.3 } });
94
+ store.add({ ...RING, frac: { x: 0.7, y: 0.7, w: 0.2, h: 0.2 } });
95
+
96
+ const got = store.within('naive', { x: 0.2, y: 0.2, w: 0.4, h: 0.4 });
97
+ expect(got.map((a) => a.id)).toEqual([inside]);
98
+ });
99
+
100
+ it('patches meaning without moving geometry, and geometry without losing meaning', () => {
101
+ const store = makeStore();
102
+ const id = store.add(RING);
103
+
104
+ store.update(id, { status: 'fixed' });
105
+ expect(store.get(id)?.status).toBe('fixed');
106
+ expect(store.get(id)?.frac).toEqual(RING.frac);
107
+
108
+ store.update(id, { frac: { x: 0.1, y: 0.1, w: 0.3, h: 0.3 } });
109
+ expect(store.get(id)?.frac).toEqual({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 });
110
+ expect(store.get(id)?.status).toBe('fixed');
111
+ expect(store.get(id)?.title).toBe(RING.title);
112
+ });
113
+
114
+ it('replaces meta and nothing else', () => {
115
+ const store = makeStore();
116
+ const id = store.add(RING);
117
+ store.setMeta(id, { engines: ['naive', 'occt'] });
118
+ expect(store.get(id)?.meta).toEqual({ engines: ['naive', 'occt'] });
119
+ expect(store.get(id)?.title).toBe(RING.title);
120
+ });
121
+
122
+ it('removes a mark', () => {
123
+ const store = makeStore();
124
+ const id = store.add(RING);
125
+ store.remove(id);
126
+ expect(store.get(id)).toBeUndefined();
127
+ expect(store.query()).toHaveLength(0);
128
+ });
129
+
130
+ it('answers staleness from the target the mark is on', () => {
131
+ const store = makeStore();
132
+ const id = store.add(RING, { angle: 'iso', shading: 'outline' });
133
+ const mark = store.get(id);
134
+ if (!mark) throw new Error('unreachable');
135
+
136
+ expect(store.isStale(mark, { angle: 'iso', shading: 'outline' })).toBe(false);
137
+ expect(store.isStale(mark, { angle: 'top', shading: 'outline' })).toBe(true);
138
+ // render_px is not declared, so it cannot make anything stale.
139
+ expect(store.isStale(mark, { angle: 'iso', shading: 'outline', render_px: 1 })).toBe(false);
140
+ });
141
+
142
+ it('notifies subscribers on each mutation, and stops after unsubscribe', () => {
143
+ const store = makeStore();
144
+ const fn = vi.fn();
145
+ const off = store.subscribe(fn);
146
+
147
+ const id = store.add(RING);
148
+ store.update(id, { status: 'fixed' });
149
+ store.remove(id);
150
+ expect(fn.mock.calls.length).toBeGreaterThanOrEqual(3);
151
+
152
+ const before = fn.mock.calls.length;
153
+ off();
154
+ store.add(RING);
155
+ expect(fn.mock.calls.length).toBe(before);
156
+ });
157
+
158
+ it('round-trips through JSON, and the snapshot is JSON-clean', () => {
159
+ const store = makeStore();
160
+ const id = store.add(RING, { angle: 'iso', shading: 'outline' });
161
+
162
+ const out = store.toJSON();
163
+ // Assert JSON-safety directly rather than trusting the shape by eye:
164
+ // labkit stringifies record.state raw, because Instrument.serialize never
165
+ // runs, so a Map or a class instance in here would be lost silently.
166
+ expect(JSON.parse(JSON.stringify(out))).toEqual(out);
167
+ expect(out.version).toBe(1);
168
+ // One scene per target, and only the ones that hold a mark.
169
+ expect(Object.keys(out.scenes)).toEqual(['naive']);
170
+
171
+ const revived = annotationsFromJSON(JSON.parse(JSON.stringify(out)), () => TARGETS);
172
+ expect(revived.get(id)).toEqual(store.get(id));
173
+ expect(revived.query()).toHaveLength(1);
174
+ });
175
+ });