@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.
- package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
- package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
- package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
- package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
- package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
- package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
- package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
- package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
- package/dist/canvas/index.d.ts +43 -25
- package/dist/canvas/index.js +5 -4
- package/dist/chrome/index.d.ts +9 -6
- package/dist/chrome/index.js +6 -6
- package/dist/chunk-2AYGEN57.js +32 -0
- package/dist/chunk-2AYGEN57.js.map +1 -0
- package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
- package/dist/chunk-3P5TTTA7.js.map +1 -0
- package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
- package/dist/chunk-3ZELRRBV.js.map +1 -0
- package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
- package/dist/chunk-4EY67BKJ.js.map +1 -0
- package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
- package/dist/chunk-5TCEVMPI.js.map +1 -0
- package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
- package/dist/chunk-6JMU56CU.js.map +1 -0
- package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
- package/dist/chunk-CQLQPQ4P.js.map +1 -0
- package/dist/chunk-CTRKTLYZ.js +33 -0
- package/dist/chunk-CTRKTLYZ.js.map +1 -0
- package/dist/chunk-EQIB2PGC.js +458 -0
- package/dist/chunk-EQIB2PGC.js.map +1 -0
- package/dist/chunk-HEORBLVT.js +7128 -0
- package/dist/chunk-HEORBLVT.js.map +1 -0
- package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
- package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
- package/dist/chunk-LITE5YFB.js +669 -0
- package/dist/chunk-LITE5YFB.js.map +1 -0
- package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
- package/dist/chunk-ROQDRXKW.js.map +1 -0
- package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
- package/dist/chunk-XJ6N32QP.js.map +1 -0
- package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
- package/dist/chunk-YQJNT4XQ.js.map +1 -0
- package/dist/controls/index.d.ts +4 -28
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -5
- package/dist/dragdrop/index.js +3 -3
- package/dist/index.d.ts +518 -190
- package/dist/index.js +1753 -75
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +187 -0
- package/dist/loupe/index.js +7 -0
- package/dist/loupe/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +3 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +138 -91
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +5 -5
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +229 -1
- package/dist/surface/index.d.ts +18 -2
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +5 -4
- package/package.json +14 -7
- package/src/annotations/AnnotationOverlay.tsx +205 -0
- package/src/annotations/AnnotationTargets.tsx +37 -0
- package/src/annotations/Annotations.less +129 -0
- package/src/annotations/Annotations.meaning.test.tsx +111 -0
- package/src/annotations/Annotations.overlay.test.tsx +142 -0
- package/src/annotations/AnnotationsContext.ts +49 -0
- package/src/annotations/ExportMenu.test.tsx +86 -0
- package/src/annotations/ExportMenu.tsx +182 -0
- package/src/annotations/MarkList.tsx +75 -0
- package/src/annotations/capture.test.ts +140 -0
- package/src/annotations/capture.ts +237 -0
- package/src/annotations/drawOne.test.ts +56 -0
- package/src/annotations/drawOne.ts +47 -0
- package/src/annotations/frac.test.ts +59 -0
- package/src/annotations/frac.ts +66 -0
- package/src/annotations/history.test.ts +82 -0
- package/src/annotations/history.ts +118 -0
- package/src/annotations/index.ts +37 -0
- package/src/annotations/paint.test.ts +125 -0
- package/src/annotations/paint.ts +130 -0
- package/src/annotations/staleness.test.ts +56 -0
- package/src/annotations/staleness.ts +41 -0
- package/src/annotations/store.test.ts +175 -0
- package/src/annotations/store.ts +317 -0
- package/src/annotations/svgNodes.test.ts +77 -0
- package/src/annotations/svgNodes.ts +74 -0
- package/src/annotations/toolMap.test.ts +36 -0
- package/src/annotations/toolMap.ts +54 -0
- package/src/annotations/types.ts +222 -0
- package/src/annotations/view.test.ts +37 -0
- package/src/annotations/view.ts +40 -0
- package/src/canvas/AGENTS.md +55 -5
- package/src/canvas/CanvasStack.test.tsx +27 -1
- package/src/canvas/CanvasStack.tsx +29 -4
- package/src/canvas/CanvasStackContext.ts +23 -4
- package/src/canvas/camera.test.ts +78 -0
- package/src/canvas/camera.ts +62 -0
- package/src/canvas/canvasCoords.test.ts +26 -0
- package/src/canvas/canvasCoords.ts +20 -7
- package/src/canvas/index.ts +5 -1
- package/src/canvas/useLayerScheduler.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +36 -5
- package/src/canvas/usePanZoom.ts +20 -15
- package/src/canvas/worldSpec.test.ts +63 -0
- package/src/canvas/worldSpec.ts +48 -0
- package/src/chrome/builtins.test.ts +31 -1
- package/src/chrome/builtins.tsx +63 -19
- package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
- package/src/chrome/regions/PaletteRegion.tsx +4 -0
- package/src/chrome/regions/SidebarRegion.tsx +37 -13
- package/src/chrome/regions/ToolbarRegion.tsx +2 -1
- package/src/chrome/regions/ViewportRegion.tsx +9 -1
- package/src/chrome/regions/regions.test.tsx +6 -1
- package/src/chrome/types.ts +20 -0
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +50 -3
- package/src/dragdrop/DragDropRuntime.tsx +8 -2
- package/src/dragdrop/dragDrop.test.tsx +29 -2
- package/src/index.test.ts +22 -0
- package/src/index.ts +87 -2
- package/src/instrument/SineWave.smoke.test.tsx +1 -0
- package/src/instrument/types.ts +24 -1
- package/src/lab/Lab.surface.test.tsx +162 -0
- package/src/lab/Lab.tsx +102 -17
- package/src/lab/Lab.undock.test.tsx +85 -0
- package/src/lab/LabFullChrome.stories.tsx +20 -0
- package/src/lab/LabShell.less +11 -0
- package/src/lab/Workspace.less +73 -0
- package/src/lab/Workspace.surface.test.tsx +2 -0
- package/src/lab/Workspace.tsx +97 -8
- package/src/lab/index.ts +1 -1
- package/src/lab/panelHost.ts +37 -0
- package/src/loupe/AGENTS.md +49 -0
- package/src/loupe/CanvasLoupe.tsx +83 -0
- package/src/loupe/DomLoupe.tsx +62 -0
- package/src/loupe/Loupe.less +38 -0
- package/src/loupe/LoupeBubble.tsx +32 -0
- package/src/loupe/TrialLoupe.tsx +93 -0
- package/src/loupe/canvasLens.test.ts +205 -0
- package/src/loupe/canvasLens.ts +141 -0
- package/src/loupe/index.ts +19 -0
- package/src/loupe/types.test.ts +29 -0
- package/src/loupe/types.ts +90 -0
- package/src/loupe/useHostSize.ts +30 -0
- package/src/loupe/useLoupe.test.tsx +197 -0
- package/src/loupe/useLoupe.ts +189 -0
- package/src/passthrough/weasel-ui.ts +3 -0
- package/src/primitives/FloatingPanel.test.tsx +76 -1
- package/src/primitives/FloatingPanel.tsx +19 -3
- package/src/primitives/ScaleIndicator.test.tsx +7 -2
- package/src/primitives/Sidebar.less +18 -0
- package/src/primitives/Toolbar.less +8 -0
- package/src/primitives/Toolbar.tsx +5 -0
- package/src/primitives/useRovingTabIndex.test.ts +21 -0
- package/src/primitives/useRovingTabIndex.ts +26 -17
- package/src/state/document.test.ts +23 -0
- package/src/state/document.ts +14 -2
- package/src/state/index.ts +2 -0
- package/src/state/store.test.ts +24 -0
- package/src/state/store.ts +35 -1
- package/src/state/types.ts +11 -0
- package/src/state/undock.test.ts +40 -0
- package/src/state/undock.ts +39 -0
- package/src/styles.less +2 -0
- package/src/surface/SurfaceContext.ts +4 -0
- package/src/surface/index.ts +8 -3
- package/src/surface/useSurfaceTile.test.tsx +2 -0
- package/src/surface/useSurfaceTile.ts +7 -1
- package/src/surface/useTiledSurface.test.tsx +58 -0
- package/src/surface/useTiledSurface.ts +38 -3
- package/src/trial/Trial.annotations.persist.test.tsx +166 -0
- package/src/trial/Trial.annotations.test.tsx +121 -0
- package/src/trial/Trial.canvas.test.tsx +76 -1
- package/src/trial/Trial.less +19 -0
- package/src/trial/Trial.loupe.test.tsx +128 -0
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +12 -0
- package/src/trial/Trial.trialId.test.tsx +36 -0
- package/src/trial/Trial.tsx +215 -40
- package/src/trial/TrialChrome.tsx +35 -0
- package/src/trial/UndockedSections.tsx +65 -0
- package/src/trial/trialOps.test.ts +21 -0
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BttTIent.d.ts +0 -314
- package/dist/chunk-3XXPU73K.js.map +0 -1
- package/dist/chunk-4TMMVIDM.js.map +0 -1
- package/dist/chunk-6X5RBC6G.js +0 -366
- package/dist/chunk-6X5RBC6G.js.map +0 -1
- package/dist/chunk-BDJXIBRZ.js.map +0 -1
- package/dist/chunk-CPUJ3QXL.js.map +0 -1
- package/dist/chunk-JDROYQM3.js.map +0 -1
- package/dist/chunk-LGKRIUSW.js.map +0 -1
- package/dist/chunk-QCICIHEO.js.map +0 -1
- package/dist/chunk-QRYPXSGP.js.map +0 -1
- package/dist/chunk-RGT4EJIB.js +0 -6961
- package/dist/chunk-RGT4EJIB.js.map +0 -1
- package/dist/chunk-T66HU3HX.js +0 -17
- package/dist/chunk-T66HU3HX.js.map +0 -1
- 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
|
+
});
|