@weasel-js/labkit 1.7.0 → 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.
- package/README.md +3 -3
- package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
- package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
- package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
- package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
- package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
- package/dist/_dts/{useTrialState-CTZPgeZ7.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
- package/dist/canvas/index.d.ts +8 -6
- package/dist/canvas/index.js +6 -6
- package/dist/chrome/index.d.ts +7 -5
- package/dist/chrome/index.js +10 -9
- package/dist/chunk-2ES4W47N.js +8 -0
- package/dist/chunk-2ES4W47N.js.map +1 -0
- package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
- package/dist/chunk-2N7UBSS2.js.map +1 -0
- package/dist/chunk-4KSC7NXL.js +15491 -0
- package/dist/chunk-4KSC7NXL.js.map +1 -0
- package/dist/{chunk-7BO7HNGF.js → chunk-CEGFXTBD.js} +8 -61
- package/dist/chunk-CEGFXTBD.js.map +1 -0
- package/dist/{chunk-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
- package/dist/chunk-E23Q6A3Y.js.map +1 -0
- package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
- package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
- package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
- package/dist/chunk-EESJUSYR.js.map +1 -0
- package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
- package/dist/chunk-EFXRM6XQ.js.map +1 -0
- package/dist/chunk-F4ACIK7Z.js +210 -0
- package/dist/chunk-F4ACIK7Z.js.map +1 -0
- package/dist/{chunk-FMK7UQ6V.js → chunk-FB4MIZOO.js} +61 -44
- package/dist/chunk-FB4MIZOO.js.map +1 -0
- package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
- package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
- package/dist/{chunk-I5DUQ5HM.js → chunk-GAHELLJT.js} +75 -72
- package/dist/chunk-GAHELLJT.js.map +1 -0
- package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
- package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
- package/dist/{chunk-T5IBSPAM.js → chunk-JFOJESJF.js} +33 -23
- package/dist/chunk-JFOJESJF.js.map +1 -0
- package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
- package/dist/chunk-PMLZP7RT.js.map +1 -0
- package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
- package/dist/chunk-READTDQX.js.map +1 -0
- package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
- package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
- package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
- package/dist/chunk-T6DLGEJB.js.map +1 -0
- package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
- package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
- package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
- package/dist/chunk-VYVGVBGY.js.map +1 -0
- package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
- package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
- package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
- package/dist/chunk-XE5HJOPL.js.map +1 -0
- package/dist/chunk-XVUKDSYV.js +38 -0
- package/dist/chunk-XVUKDSYV.js.map +1 -0
- package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
- package/dist/chunk-YCFKW5RX.js.map +1 -0
- package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
- package/dist/chunk-ZQIHJXP4.js.map +1 -0
- package/dist/config/index.d.ts +18 -10
- package/dist/config/index.js +4 -4
- package/dist/controls/index.d.ts +4 -7
- package/dist/controls/index.js +6 -5
- package/dist/dragdrop/index.d.ts +23 -3
- package/dist/dragdrop/index.js +4 -3
- package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
- package/dist/index.d.ts +60 -714
- package/dist/index.js +357 -334
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +8 -0
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -2
- package/dist/loupe/index.d.ts +100 -30
- package/dist/loupe/index.js +585 -6
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.d.ts +4 -2
- package/dist/overview/index.js +15 -14
- package/dist/overview/index.js.map +1 -1
- package/dist/passthrough/weasel-canvas.d.ts +2 -0
- package/dist/passthrough/weasel-ui.d.ts +2 -2871
- package/dist/passthrough/weasel-ui.js +3 -2
- package/dist/primitives/index.d.ts +213 -3
- package/dist/primitives/index.js +8 -7
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +6 -6
- package/dist/styles.css +60 -44
- package/dist/surface/index.d.ts +10 -2
- package/dist/surface/index.js +1 -1
- package/dist/undo/index.d.ts +5 -3
- package/package.json +11 -11
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
- package/src/annotations/AnnotationOverlay.tsx +50 -31
- package/src/annotations/Annotations.overlay.test.tsx +31 -0
- package/src/annotations/capture.browser.test.ts +41 -0
- package/src/annotations/capture.ts +14 -7
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/frac.test.ts +44 -1
- package/src/annotations/frac.ts +46 -18
- package/src/annotations/index.ts +1 -0
- package/src/annotations/paint.test.ts +74 -33
- package/src/annotations/paint.ts +70 -32
- package/src/annotations/store.test.ts +212 -2
- package/src/annotations/store.ts +130 -27
- package/src/annotations/svgNodes.test.ts +22 -10
- package/src/annotations/svgNodes.ts +3 -3
- package/src/annotations/types.ts +29 -10
- package/src/canvas/CameraInput.test.tsx +30 -0
- package/src/canvas/CameraInput.tsx +16 -15
- package/src/canvas/CanvasStack.tsx +5 -5
- package/src/canvas/CanvasStackContext.ts +4 -1
- package/src/canvas/Stage.tsx +3 -3
- package/src/canvas/cameraRegistry.ts +1 -0
- package/src/canvas/worldSpec.ts +3 -1
- package/src/chrome/builtins.test.ts +7 -6
- package/src/chrome/builtins.tsx +5 -1
- package/src/chrome/index.ts +1 -1
- package/src/chrome/merge.test.ts +5 -0
- package/src/chrome/merge.ts +4 -2
- package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
- package/src/chrome/regions/PaletteRegion.tsx +3 -4
- package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
- package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
- package/src/chrome/regions/ViewportRegion.less +6 -0
- package/src/chrome/regions/ViewportRegion.tsx +10 -4
- package/src/chrome/types.ts +4 -2
- package/src/config/auto.ts +1 -0
- package/src/config/builder.ts +6 -0
- package/src/config/types.ts +2 -1
- package/src/controls/ControlPanel.test.tsx +37 -3
- package/src/controls/ControlPanel.tsx +12 -3
- package/src/dragdrop/DragDropRuntime.tsx +22 -13
- package/src/dragdrop/DragGhost.tsx +3 -0
- package/src/dragdrop/Palette.tsx +2 -0
- package/src/index.test.ts +5 -0
- package/src/index.ts +1 -1
- package/src/instrument/types.ts +0 -6
- package/src/job/useJob.ts +14 -9
- package/src/lab/Lab.tsx +5 -1
- package/src/lab/LabShell.less +1 -0
- package/src/lab/LabSwitcher.browser.test.tsx +27 -0
- package/src/lab/LabSwitcher.less +2 -0
- package/src/lab/LabZoom.tsx +4 -4
- package/src/lab/Workspace.tsx +48 -52
- package/src/loupe/AGENTS.md +28 -15
- package/src/loupe/CanvasLoupe.tsx +2 -3
- package/src/loupe/DomLoupe.tsx +1 -7
- package/src/loupe/Loupe.less +16 -0
- package/src/loupe/TrialLoupe.tsx +120 -57
- package/src/loupe/index.ts +1 -6
- package/src/loupe/types.test.ts +3 -3
- package/src/loupe/types.ts +24 -27
- package/src/loupe/useLoupe.test.tsx +29 -4
- package/src/loupe/useLoupe.ts +20 -17
- package/src/overview/OverviewMarks.tsx +12 -4
- package/src/overview/TrialOverview.tsx +3 -3
- package/src/passthrough/weasel-ui.test.ts +1 -1
- package/src/passthrough/weasel-ui.ts +1 -1
- package/src/primitives/FloatingPanel.tsx +7 -3
- package/src/primitives/JobProgress.less +1 -0
- package/src/primitives/Readout.test.tsx +2 -2
- package/src/primitives/Split.tsx +69 -41
- package/src/primitives/Toolbar.tsx +3 -3
- package/src/primitives/ZoomControl.less +1 -2
- package/src/specimen/Specimen.tsx +0 -1
- package/src/state/undock.ts +3 -0
- package/src/state/useOpenOnce.ts +20 -16
- package/src/state/view.ts +1 -0
- package/src/surface/index.ts +1 -1
- package/src/surface/useTiledSurface.test.tsx +14 -0
- package/src/surface/useTiledSurface.ts +11 -3
- package/src/trial/Trial.annotations.test.tsx +73 -0
- package/src/trial/Trial.less +0 -16
- package/src/trial/Trial.loupe.test.tsx +28 -11
- package/src/trial/Trial.tsx +74 -62
- package/src/trial/TrialChrome.tsx +6 -3
- package/src/trial/index.ts +2 -0
- package/src/trial/loupeSwitch.ts +15 -0
- package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
- package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
- package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
- package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
- package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
- package/dist/chunk-275JRRVD.js.map +0 -1
- package/dist/chunk-4LJSC7MB.js +0 -143
- package/dist/chunk-4LJSC7MB.js.map +0 -1
- package/dist/chunk-7BO7HNGF.js.map +0 -1
- package/dist/chunk-CQLQPQ4P.js.map +0 -1
- package/dist/chunk-FMK7UQ6V.js.map +0 -1
- package/dist/chunk-FZ7YFQN7.js.map +0 -1
- package/dist/chunk-HXXTULDN.js.map +0 -1
- package/dist/chunk-I5DUQ5HM.js.map +0 -1
- package/dist/chunk-IJJUK4FU.js.map +0 -1
- package/dist/chunk-J7OWWTCW.js.map +0 -1
- package/dist/chunk-M7HP3FTK.js +0 -549
- package/dist/chunk-M7HP3FTK.js.map +0 -1
- package/dist/chunk-MIM5R4ZT.js +0 -14972
- package/dist/chunk-MIM5R4ZT.js.map +0 -1
- package/dist/chunk-RWNDDKOH.js.map +0 -1
- package/dist/chunk-T5IBSPAM.js.map +0 -1
- package/dist/chunk-UNAZSLWE.js.map +0 -1
- package/dist/chunk-WPLAANH4.js.map +0 -1
- package/dist/chunk-Y23G2VZL.js.map +0 -1
- package/dist/chunk-ZIFPHIWZ.js.map +0 -1
- package/src/primitives/useRovingTabIndex.test.ts +0 -48
- package/src/primitives/useRovingTabIndex.ts +0 -88
package/src/annotations/frac.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Box,
|
|
3
|
+
boundsOfCoords,
|
|
4
|
+
boxContainsBox,
|
|
5
|
+
boxContainsPoint,
|
|
6
|
+
type Rect,
|
|
7
|
+
} from '@weasel-js/geom';
|
|
1
8
|
import type { FracPoint, FracRect } from './types';
|
|
2
9
|
|
|
3
10
|
/** A mark's box in its target's world: CSS pixels at zoom 1. Matches weasel's
|
|
4
11
|
* default `RectPose`, which is what the scene stores. */
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
width: number;
|
|
9
|
-
height: number;
|
|
10
|
-
}
|
|
12
|
+
export type WorldRect = Rect;
|
|
13
|
+
|
|
14
|
+
const boxOfFrac = (f: FracRect): Box => [f.x, f.y, f.x + f.w, f.y + f.h];
|
|
11
15
|
|
|
12
16
|
/** Where a fraction lands on a content box of this size. */
|
|
13
17
|
export function fracToWorld(f: FracRect, content: { w: number; h: number }): WorldRect {
|
|
@@ -46,22 +50,46 @@ export function roundFrac(f: FracRect): FracRect {
|
|
|
46
50
|
/** Whether `pt` falls in `box`, widened by `tol` on every side so a hairline
|
|
47
51
|
* mark stays reachable. */
|
|
48
52
|
export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
pt.x <= box.x + box.w + tol &&
|
|
52
|
-
pt.y >= box.y - tol &&
|
|
53
|
-
pt.y <= box.y + box.h + tol
|
|
54
|
-
);
|
|
53
|
+
const [x0, y0, x1, y1] = boxOfFrac(box);
|
|
54
|
+
return boxContainsPoint([x0 - tol, y0 - tol, x1 + tol, y1 + tol], pt.x, pt.y);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
|
|
58
58
|
* not what it grazes — brushing selection is a different gesture, and this
|
|
59
59
|
* answers false for two rects that merely overlap. */
|
|
60
60
|
export function fracEncloses(outer: FracRect, inner: FracRect): boolean {
|
|
61
|
-
return (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
return boxContainsBox(boxOfFrac(outer), boxOfFrac(inner));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The smallest box holding every point — zero-size for a single one, and
|
|
65
|
+
* `null` for none. */
|
|
66
|
+
export function boundsOf(points: readonly FracPoint[]): FracRect | null {
|
|
67
|
+
const b = boundsOfCoords(points.flatMap((p) => [p.x, p.y]));
|
|
68
|
+
return b && { x: b[0], y: b[1], w: b[2] - b[0], h: b[3] - b[1] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Points restated as fractions of `box` itself, so they move and stretch with
|
|
72
|
+
* it. Unit-agnostic: `points` and `box` only have to share one. A box with no
|
|
73
|
+
* width or height puts every point on its edge along that axis. */
|
|
74
|
+
export function toShape(points: readonly FracPoint[], box: FracRect): FracPoint[] {
|
|
75
|
+
return points.map((p) => ({
|
|
76
|
+
x: box.w === 0 ? 0 : (p.x - box.x) / box.w,
|
|
77
|
+
y: box.h === 0 ? 0 : (p.y - box.y) / box.h,
|
|
78
|
+
}));
|
|
67
79
|
}
|
|
80
|
+
|
|
81
|
+
/** `toShape`'s inverse: a shape placed in `box`, in `box`'s units. */
|
|
82
|
+
export function fromShape(shape: readonly FracPoint[], box: FracRect): FracPoint[] {
|
|
83
|
+
return shape.map((s) => ({ x: box.x + s.x * box.w, y: box.y + s.y * box.h }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A world rect as the `{ x, y, w, h }` the shape helpers take. */
|
|
87
|
+
export const boxOf = (r: WorldRect): FracRect => ({ x: r.x, y: r.y, w: r.width, h: r.height });
|
|
88
|
+
|
|
89
|
+
const roundPoint = (p: FracPoint): FracPoint => ({
|
|
90
|
+
x: Math.round(p.x * DP) / DP,
|
|
91
|
+
y: Math.round(p.y * DP) / DP,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** `roundFrac` for points. */
|
|
95
|
+
export const roundPoints = (pts: readonly FracPoint[]): FracPoint[] => pts.map(roundPoint);
|
package/src/annotations/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { registerNodeShape, type SceneNode, shapeCoversPoint } from '@weasel-js/core';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import type { WorldRect } from './frac';
|
|
4
|
+
import { markCommands, POINT_MARK_SHAPE } from './paint';
|
|
3
5
|
import type { AnnotationData, AnnotationKind, FracPoint } from './types';
|
|
4
6
|
|
|
5
|
-
const CONTENT = { w: 200, h: 100 };
|
|
6
7
|
const POSE = { x: 20, y: 10, width: 60, height: 40 };
|
|
8
|
+
const AT_50: WorldRect = { x: 50, y: 50, width: 0, height: 0 };
|
|
7
9
|
|
|
8
10
|
function mark(kind: AnnotationKind, extra: Partial<AnnotationData> = {}) {
|
|
9
11
|
return { pose: POSE, data: { target: 't', kind, ...extra } as AnnotationData };
|
|
@@ -17,20 +19,21 @@ function anchors(cmds: ReturnType<typeof markCommands>): number[] {
|
|
|
17
19
|
return p.coords ? [...p.coords] : [];
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** In fractions of `POSE`: (35, 10) and (80, 30) in world. */
|
|
20
23
|
const ENDS: readonly FracPoint[] = [
|
|
21
|
-
{ x: 0.
|
|
22
|
-
{ x:
|
|
24
|
+
{ x: 0.25, y: 0 },
|
|
25
|
+
{ x: 1, y: 0.5 },
|
|
23
26
|
];
|
|
24
27
|
|
|
25
28
|
describe('markCommands', () => {
|
|
26
29
|
it('draws a rect at the pose', () => {
|
|
27
|
-
const [cmd] = markCommands(mark('rect')
|
|
30
|
+
const [cmd] = markCommands(mark('rect'));
|
|
28
31
|
expect(cmd).toMatchObject({ kind: 'path', path: { kind: 'rect', ...POSE } });
|
|
29
32
|
expect((cmd as { fill?: unknown }).fill).toBeUndefined();
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
it('draws an ellipse inscribed in the pose', () => {
|
|
33
|
-
const pts = anchors(markCommands(mark('ellipse')
|
|
36
|
+
const pts = anchors(markCommands(mark('ellipse')));
|
|
34
37
|
const xs = pts.filter((_, i) => i % 2 === 0);
|
|
35
38
|
const ys = pts.filter((_, i) => i % 2 === 1);
|
|
36
39
|
expect(Math.min(...xs)).toBeCloseTo(POSE.x, 4);
|
|
@@ -39,21 +42,16 @@ describe('markCommands', () => {
|
|
|
39
42
|
expect(Math.max(...ys)).toBeCloseTo(POSE.y + POSE.height, 4);
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
it('runs a line between its two stored ends,
|
|
43
|
-
expect(anchors(markCommands(mark('line', {
|
|
44
|
-
0.1 * 200,
|
|
45
|
-
0.2 * 100,
|
|
46
|
-
0.6 * 200,
|
|
47
|
-
0.9 * 100,
|
|
48
|
-
]);
|
|
45
|
+
it('runs a line between its two stored ends, placed in the pose', () => {
|
|
46
|
+
expect(anchors(markCommands(mark('line', { shape: ENDS })))).toEqual([35, 10, 80, 30]);
|
|
49
47
|
});
|
|
50
48
|
|
|
51
49
|
it('makes an arrow the same line, carrying an end marker', () => {
|
|
52
|
-
const cmds = markCommands(mark('arrow', {
|
|
50
|
+
const cmds = markCommands(mark('arrow', { shape: ENDS }));
|
|
53
51
|
expect(cmds).toHaveLength(1);
|
|
54
52
|
const stroke = (cmds[0] as { stroke?: { markerEnd?: unknown } }).stroke;
|
|
55
53
|
expect(stroke?.markerEnd).toBe('arrow');
|
|
56
|
-
expect(anchors(cmds)).toEqual([
|
|
54
|
+
expect(anchors(cmds)).toEqual([35, 10, 80, 30]);
|
|
57
55
|
});
|
|
58
56
|
|
|
59
57
|
it('threads a freehand stroke through every point', () => {
|
|
@@ -62,15 +60,13 @@ describe('markCommands', () => {
|
|
|
62
60
|
{ x: 0.5, y: 0.5 },
|
|
63
61
|
{ x: 1, y: 0 },
|
|
64
62
|
];
|
|
65
|
-
expect(anchors(markCommands(mark('stroke', {
|
|
66
|
-
0, 0, 100, 50, 200, 0,
|
|
67
|
-
]);
|
|
63
|
+
expect(anchors(markCommands(mark('stroke', { shape: pts })))).toEqual([20, 10, 50, 30, 80, 10]);
|
|
68
64
|
});
|
|
69
65
|
|
|
70
|
-
it('falls back to the pose diagonal when a
|
|
71
|
-
// A stored mark from a writer that dropped `
|
|
66
|
+
it('falls back to the pose diagonal when a vertex kind has no shape', () => {
|
|
67
|
+
// A stored mark from a writer that dropped `shape` must still draw
|
|
72
68
|
// something at the right place, not vanish.
|
|
73
|
-
expect(anchors(markCommands(mark('line')
|
|
69
|
+
expect(anchors(markCommands(mark('line')))).toEqual([
|
|
74
70
|
POSE.x,
|
|
75
71
|
POSE.y,
|
|
76
72
|
POSE.x + POSE.width,
|
|
@@ -79,18 +75,18 @@ describe('markCommands', () => {
|
|
|
79
75
|
});
|
|
80
76
|
|
|
81
77
|
it('draws text at the pose, carrying the title', () => {
|
|
82
|
-
const [cmd] = markCommands(mark('text', { title: 'missing edge' })
|
|
78
|
+
const [cmd] = markCommands(mark('text', { title: 'missing edge' }));
|
|
83
79
|
expect(cmd).toMatchObject({ kind: 'text', x: POSE.x, y: POSE.y });
|
|
84
80
|
const runs = (cmd as { runs: { text: string }[] }).runs;
|
|
85
81
|
expect(runs.map((r) => r.text).join('')).toBe('missing edge');
|
|
86
82
|
});
|
|
87
83
|
|
|
88
84
|
it('draws an untitled text mark as nothing rather than an empty run', () => {
|
|
89
|
-
expect(markCommands(mark('text')
|
|
85
|
+
expect(markCommands(mark('text'))).toEqual([]);
|
|
90
86
|
});
|
|
91
87
|
|
|
92
|
-
it('draws a point as a small unfilled ring centered on its
|
|
93
|
-
const cmds = markCommands(
|
|
88
|
+
it('draws a point as a small unfilled ring centered on its position', () => {
|
|
89
|
+
const cmds = markCommands({ pose: AT_50, data: { target: 't', kind: 'point' } });
|
|
94
90
|
expect(cmds).toHaveLength(1);
|
|
95
91
|
expect((cmds[0] as { fill?: unknown }).fill).toBeUndefined();
|
|
96
92
|
const pts = anchors(cmds);
|
|
@@ -104,9 +100,56 @@ describe('markCommands', () => {
|
|
|
104
100
|
});
|
|
105
101
|
});
|
|
106
102
|
|
|
103
|
+
describe('a point mark at different zooms', () => {
|
|
104
|
+
const POINT = { pose: AT_50, data: { target: 't', kind: 'point' } as AnnotationData };
|
|
105
|
+
const radiusAt = (zoom: number) => {
|
|
106
|
+
const xs = anchors(markCommands(POINT, {}, { x: zoom, y: zoom })).filter((_, i) => i % 2 === 0);
|
|
107
|
+
return (Math.max(...xs) - Math.min(...xs)) / 2;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
it('sizes the ring in screen pixels, so its world radius scales inversely with zoom', () => {
|
|
111
|
+
expect(radiusAt(2)).toBeCloseTo(2, 4);
|
|
112
|
+
expect(radiusAt(4)).toBeCloseTo(1, 4);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps the ring round on screen under a non-uniform scale', () => {
|
|
116
|
+
const pts = anchors(markCommands(POINT, {}, { x: 2, y: 4 }));
|
|
117
|
+
const xs = pts.filter((_, i) => i % 2 === 0);
|
|
118
|
+
const ys = pts.filter((_, i) => i % 2 === 1);
|
|
119
|
+
expect((Math.max(...xs) - Math.min(...xs)) * 2).toBeCloseTo(
|
|
120
|
+
(Math.max(...ys) - Math.min(...ys)) * 4,
|
|
121
|
+
4,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('picking a point mark', () => {
|
|
127
|
+
let dispose: () => void = () => {};
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
dispose = registerNodeShape(POINT_MARK_SHAPE, { priority: 'high' });
|
|
130
|
+
});
|
|
131
|
+
afterEach(() => dispose());
|
|
132
|
+
|
|
133
|
+
const pose: WorldRect = { x: 50, y: 50, width: 0, height: 0 };
|
|
134
|
+
const node = {
|
|
135
|
+
id: 'p',
|
|
136
|
+
kind: 'leaf',
|
|
137
|
+
layer: 'marks',
|
|
138
|
+
pose,
|
|
139
|
+
data: { target: 't', kind: 'point' },
|
|
140
|
+
} as unknown as SceneNode<AnnotationData, 'marks', WorldRect>;
|
|
141
|
+
|
|
142
|
+
// Ring radius 4px plus half the 2px outline: the ring's outer edge is 5px out.
|
|
143
|
+
it.each([1, 4])('reaches the ring it draws and no further, at zoom %i', (zoom) => {
|
|
144
|
+
const at = (px: number) => shapeCoversPoint(node, pose, 50 + px / zoom, 50, { scale: zoom });
|
|
145
|
+
expect(at(4.9)).toBe(true);
|
|
146
|
+
expect(at(5.5)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
107
150
|
describe('markCommands styling', () => {
|
|
108
151
|
it('takes a status color over the default', () => {
|
|
109
|
-
const [cmd] = markCommands(mark('rect'),
|
|
152
|
+
const [cmd] = markCommands(mark('rect'), { color: '#30a46c' });
|
|
110
153
|
expect((cmd as { stroke?: { paint?: { color?: string } } }).stroke?.paint?.color).toBe(
|
|
111
154
|
'#30a46c',
|
|
112
155
|
);
|
|
@@ -114,21 +157,19 @@ describe('markCommands styling', () => {
|
|
|
114
157
|
|
|
115
158
|
it('dashes a stale mark rather than hiding it', () => {
|
|
116
159
|
// A stale mark still describes something; dropping it would lose it.
|
|
117
|
-
const [fresh] = markCommands(mark('rect')
|
|
118
|
-
const [stale] = markCommands(mark('rect'),
|
|
160
|
+
const [fresh] = markCommands(mark('rect'));
|
|
161
|
+
const [stale] = markCommands(mark('rect'), { stale: true });
|
|
119
162
|
expect((fresh as { stroke?: { dash?: number[] } }).stroke?.dash).toBeUndefined();
|
|
120
163
|
expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
|
|
121
164
|
});
|
|
122
165
|
|
|
123
166
|
it('dashes a stale point ring like any other mark', () => {
|
|
124
|
-
const [ring] = markCommands(mark('point', {
|
|
125
|
-
stale: true,
|
|
126
|
-
});
|
|
167
|
+
const [ring] = markCommands(mark('point'), { stale: true });
|
|
127
168
|
expect((ring as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
|
|
128
169
|
});
|
|
129
170
|
|
|
130
171
|
it("colors a text mark's glyphs too, not only the outlines", () => {
|
|
131
|
-
const [cmd] = markCommands(mark('text', { title: 'x' }),
|
|
172
|
+
const [cmd] = markCommands(mark('text', { title: 'x' }), { color: '#30a46c' });
|
|
132
173
|
const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
|
|
133
174
|
expect(runs[0]?.fill?.color).toBe('#30a46c');
|
|
134
175
|
});
|
package/src/annotations/paint.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import type { DrawCommand, Path, Stroke } from '@weasel-js/core';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import type { DrawCommand, NodeShapeEntry, Path, Stroke, View } from '@weasel-js/core';
|
|
2
|
+
import {
|
|
3
|
+
ellipsePath,
|
|
4
|
+
HANDLE_BASE_PX,
|
|
5
|
+
linePath,
|
|
6
|
+
meanScale,
|
|
7
|
+
PathBuilder,
|
|
8
|
+
pxExtent,
|
|
9
|
+
rectPath,
|
|
10
|
+
textCommand,
|
|
11
|
+
} from '@weasel-js/core';
|
|
12
|
+
import { boxOf, fromShape, type WorldRect } from './frac';
|
|
13
|
+
import type { AnnotationData } from './types';
|
|
5
14
|
|
|
6
15
|
/** One loud color, not a themed one: a mark sits over the instrument's own
|
|
7
16
|
* picture and has to be legible against whatever that picture is. */
|
|
@@ -9,7 +18,11 @@ const MARK_COLOR = '#e5484d';
|
|
|
9
18
|
/** World units, so a mark thickens with the picture it annotates. */
|
|
10
19
|
const MARK_WIDTH = 2;
|
|
11
20
|
const TEXT_SIZE = 14;
|
|
12
|
-
const
|
|
21
|
+
const STALE_DASH = [6, 4];
|
|
22
|
+
/** Screen pixels: a point marks a place rather than a region of the picture,
|
|
23
|
+
* so it holds its size like a selection handle. */
|
|
24
|
+
const POINT_RADIUS_PX = HANDLE_BASE_PX / 2;
|
|
25
|
+
const UNIT_SCALE: View['scale'] = { x: 1, y: 1 };
|
|
13
26
|
|
|
14
27
|
/** The subset of a mark's scene node this needs: where it is, and what it is. */
|
|
15
28
|
export interface PaintableMark {
|
|
@@ -28,16 +41,11 @@ export interface MarkStyle {
|
|
|
28
41
|
stale?: boolean;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
/** A mark's stored vertices in world units, or the pose's diagonal — a stored
|
|
37
|
-
* mark whose `points` did not survive still has to draw somewhere. */
|
|
38
|
-
function vertices(m: PaintableMark, content: { w: number; h: number }): { x: number; y: number }[] {
|
|
39
|
-
const stored = m.data.points;
|
|
40
|
-
if (stored && stored.length >= 2) return stored.map((p) => toWorld(p, content));
|
|
44
|
+
/** A mark's stored vertices placed in its pose, or the pose's diagonal — a
|
|
45
|
+
* stored mark whose `shape` did not survive still has to draw somewhere. */
|
|
46
|
+
function vertices(m: PaintableMark): { x: number; y: number }[] {
|
|
47
|
+
const stored = m.data.shape;
|
|
48
|
+
if (stored && stored.length >= 2) return fromShape(stored, boxOf(m.pose));
|
|
41
49
|
const { x, y, width, height } = m.pose;
|
|
42
50
|
return [
|
|
43
51
|
{ x, y },
|
|
@@ -57,14 +65,18 @@ function polyline(points: { x: number; y: number }[]): Path {
|
|
|
57
65
|
/**
|
|
58
66
|
* What one mark draws, in its target's world.
|
|
59
67
|
*
|
|
60
|
-
* Pure: a node
|
|
61
|
-
*
|
|
62
|
-
*
|
|
68
|
+
* Pure: a node in, draw commands out, in its target's world. Geometry that a
|
|
69
|
+
* bounding box cannot describe — a line's ends, a stroke's path — comes from
|
|
70
|
+
* `data.shape`, placed in the pose.
|
|
71
|
+
*
|
|
72
|
+
* `scale` is the view's, world to screen. Only a point reads it — its ring is
|
|
73
|
+
* sized in screen pixels — and the default draws that ring as though a world
|
|
74
|
+
* unit were a pixel.
|
|
63
75
|
*/
|
|
64
76
|
export function markCommands(
|
|
65
77
|
m: PaintableMark,
|
|
66
|
-
content: { w: number; h: number },
|
|
67
78
|
style: MarkStyle = {},
|
|
79
|
+
scale: View['scale'] = UNIT_SCALE,
|
|
68
80
|
): DrawCommand[] {
|
|
69
81
|
const color = style.color ?? MARK_COLOR;
|
|
70
82
|
const stroke: Stroke = {
|
|
@@ -72,7 +84,7 @@ export function markCommands(
|
|
|
72
84
|
width: MARK_WIDTH,
|
|
73
85
|
cap: 'round',
|
|
74
86
|
join: 'round',
|
|
75
|
-
...(style.stale ? { dash:
|
|
87
|
+
...(style.stale ? { dash: STALE_DASH } : {}),
|
|
76
88
|
};
|
|
77
89
|
|
|
78
90
|
switch (m.data.kind) {
|
|
@@ -83,26 +95,32 @@ export function markCommands(
|
|
|
83
95
|
case 'ellipse':
|
|
84
96
|
return [{ kind: 'path', path: ellipsePath(m.pose), stroke }];
|
|
85
97
|
case 'line': {
|
|
86
|
-
const [a, b] = vertices(m
|
|
98
|
+
const [a, b] = vertices(m);
|
|
87
99
|
return [{ kind: 'path', path: linePath(a, b), stroke }];
|
|
88
100
|
}
|
|
89
101
|
case 'arrow': {
|
|
90
|
-
const [a, b] = vertices(m
|
|
102
|
+
const [a, b] = vertices(m);
|
|
91
103
|
// The spec's arrow: a line carrying an end marker, not its own geometry.
|
|
92
104
|
return [{ kind: 'path', path: linePath(a, b), stroke: { ...stroke, markerEnd: 'arrow' } }];
|
|
93
105
|
}
|
|
94
106
|
case 'stroke':
|
|
95
|
-
return [{ kind: 'path', path: polyline(vertices(m
|
|
107
|
+
return [{ kind: 'path', path: polyline(vertices(m)), stroke }];
|
|
96
108
|
case 'point': {
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const ring = {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
const c = { x: m.pose.x, y: m.pose.y };
|
|
110
|
+
const r = pxExtent(POINT_RADIUS_PX, scale);
|
|
111
|
+
const ring = { x: c.x - r.x, y: c.y - r.y, width: 2 * r.x, height: 2 * r.y };
|
|
112
|
+
const px = 1 / meanScale(scale);
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
kind: 'path',
|
|
116
|
+
path: ellipsePath(ring),
|
|
117
|
+
stroke: {
|
|
118
|
+
...stroke,
|
|
119
|
+
width: MARK_WIDTH * px,
|
|
120
|
+
...(stroke.dash ? { dash: stroke.dash.map((d) => d * px) } : {}),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
];
|
|
106
124
|
}
|
|
107
125
|
case 'text': {
|
|
108
126
|
const text = m.data.title;
|
|
@@ -124,3 +142,23 @@ export function markCommands(
|
|
|
124
142
|
}
|
|
125
143
|
}
|
|
126
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What picking reads for a point mark: its pose is zero-size, so without this
|
|
148
|
+
* the pointer slop alone decides how close counts, whatever the ring's size.
|
|
149
|
+
* Registered by the overlay, which is the one place marks are picked.
|
|
150
|
+
*/
|
|
151
|
+
export const POINT_MARK_SHAPE: NodeShapeEntry<AnnotationData, WorldRect> = {
|
|
152
|
+
id: 'labkit:point-mark',
|
|
153
|
+
matches: (node) =>
|
|
154
|
+
node.layer === 'marks' && node.data?.kind === 'point' && typeof node.data.target === 'string',
|
|
155
|
+
// The overlay paints through its own draw callback. This is for a scene
|
|
156
|
+
// drawn without one.
|
|
157
|
+
paint: (node, pose) => markCommands({ pose, data: node.data }),
|
|
158
|
+
silhouette: (_node, pose) => rectPath(pose.x, pose.y, 0, 0),
|
|
159
|
+
// Filled, so a click inside the ring lands on it as it would on a handle.
|
|
160
|
+
ink: (_node, _pose, ctx) => {
|
|
161
|
+
const s = ctx?.scale ?? 1;
|
|
162
|
+
return { filled: true, outset: (POINT_RADIUS_PX + MARK_WIDTH / 2) / s, inset: 0 };
|
|
163
|
+
},
|
|
164
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { asNodeId } from '@weasel-js/core';
|
|
1
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { markCommands } from './paint';
|
|
2
4
|
import { annotationsFromJSON, createAnnotationStore } from './store';
|
|
3
|
-
import type { AnnotationInit, AnnotationTargetInfo } from './types';
|
|
5
|
+
import type { AnnotationInit, AnnotationsApi, AnnotationTargetInfo, FracPoint } from './types';
|
|
4
6
|
|
|
5
7
|
const TARGETS: AnnotationTargetInfo[] = [
|
|
6
8
|
{ id: 'naive', content: { w: 256, h: 170 }, positionDependsOn: ['angle', 'shading'] },
|
|
@@ -162,7 +164,7 @@ describe('the annotation store', () => {
|
|
|
162
164
|
// because jsdom has no WebGL2 and the overlay's canvas never paints or
|
|
163
165
|
// hit-tests. That the canvas is bound to this scene is core's claim.
|
|
164
166
|
it('merges the selection across targets, and maps it back to annotation ids', () => {
|
|
165
|
-
const store =
|
|
167
|
+
const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
|
|
166
168
|
const onNaive = store.add(RING);
|
|
167
169
|
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
168
170
|
|
|
@@ -227,6 +229,86 @@ describe('the annotation store', () => {
|
|
|
227
229
|
expect(fn.mock.calls.length).toBe(before);
|
|
228
230
|
});
|
|
229
231
|
|
|
232
|
+
describe('across targets', () => {
|
|
233
|
+
/** Selects a mark the way its pane's canvas does: on that target's scene. */
|
|
234
|
+
const click = (store: AnnotationsApi, id: string): void => {
|
|
235
|
+
const [target, node] = id.split('/') as [string, string];
|
|
236
|
+
store.sceneFor(target).setSelection([asNodeId(node)]);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
it('clears a selection standing in one target when a mark is selected in another', () => {
|
|
240
|
+
const store = makeStore();
|
|
241
|
+
const onNaive = store.add(RING);
|
|
242
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
243
|
+
|
|
244
|
+
click(store, onNaive);
|
|
245
|
+
click(store, onOcct);
|
|
246
|
+
expect(store.selection()).toEqual([onOcct]);
|
|
247
|
+
expect(store.sceneFor('naive').getSelection()).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('tells subscribers once, after the other target is already cleared', () => {
|
|
251
|
+
const store = makeStore();
|
|
252
|
+
const onNaive = store.add(RING);
|
|
253
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
254
|
+
click(store, onNaive);
|
|
255
|
+
|
|
256
|
+
const seen: (readonly string[])[] = [];
|
|
257
|
+
store.subscribe(() => seen.push(store.selection()));
|
|
258
|
+
click(store, onOcct);
|
|
259
|
+
expect(seen).toEqual([[onOcct]]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('keeps a setSelection naming two targets to the first one, in one event', () => {
|
|
263
|
+
const store = makeStore();
|
|
264
|
+
const onNaive = store.add(RING);
|
|
265
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
266
|
+
click(store, onNaive);
|
|
267
|
+
|
|
268
|
+
const fn = vi.fn();
|
|
269
|
+
store.subscribe(fn);
|
|
270
|
+
store.setSelection([onOcct, onNaive]);
|
|
271
|
+
expect(store.selection()).toEqual([onOcct]);
|
|
272
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('leaves the other target alone for a change that selects nothing new', () => {
|
|
276
|
+
const store = makeStore();
|
|
277
|
+
const onNaive = store.add(RING);
|
|
278
|
+
store.add({ ...RING, target: 'occt' });
|
|
279
|
+
click(store, onNaive);
|
|
280
|
+
|
|
281
|
+
store.add({ ...RING, target: 'occt' });
|
|
282
|
+
store.sceneFor('occt').setSelection([]);
|
|
283
|
+
expect(store.selection()).toEqual([onNaive]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("keeps each target's own selection under `selection: 'per-target'`", () => {
|
|
287
|
+
const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
|
|
288
|
+
const onNaive = store.add(RING);
|
|
289
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
290
|
+
|
|
291
|
+
click(store, onNaive);
|
|
292
|
+
click(store, onOcct);
|
|
293
|
+
expect(store.selection()).toEqual([onNaive, onOcct]);
|
|
294
|
+
|
|
295
|
+
store.setSelection([onOcct, onNaive]);
|
|
296
|
+
expect(store.selection()).toEqual([onNaive, onOcct]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('reads the mode at each change, for a capability swapped under a live store', () => {
|
|
300
|
+
let mode: 'exclusive' | 'per-target' = 'per-target';
|
|
301
|
+
const store = createAnnotationStore({ targets: () => TARGETS, selection: () => mode });
|
|
302
|
+
const onNaive = store.add(RING);
|
|
303
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
304
|
+
|
|
305
|
+
click(store, onNaive);
|
|
306
|
+
mode = 'exclusive';
|
|
307
|
+
click(store, onOcct);
|
|
308
|
+
expect(store.selection()).toEqual([onOcct]);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
230
312
|
it('round-trips through JSON, and the snapshot is JSON-clean', () => {
|
|
231
313
|
const store = makeStore();
|
|
232
314
|
const id = store.add(RING, { angle: 'iso', shading: 'outline' });
|
|
@@ -295,6 +377,134 @@ describe('the annotation store', () => {
|
|
|
295
377
|
});
|
|
296
378
|
});
|
|
297
379
|
|
|
380
|
+
/** What a target's first mark draws, as its path's points in fractions of the
|
|
381
|
+
* content box — the drawn side, to hold against what the store hit-tests. */
|
|
382
|
+
function drawn(store: AnnotationsApi, target = 'naive'): FracPoint[] {
|
|
383
|
+
const { w, h } = TARGETS.find((t) => t.id === target)?.content ?? { w: 0, h: 0 };
|
|
384
|
+
const [painted] = store.paintedMarks(target);
|
|
385
|
+
if (!painted) throw new Error('no mark painted');
|
|
386
|
+
const [cmd] = markCommands(painted.mark, painted.style);
|
|
387
|
+
if (cmd?.kind !== 'path') throw new Error('expected a path command');
|
|
388
|
+
const coords = [...((cmd.path as { coords?: Float32Array }).coords ?? [])];
|
|
389
|
+
const out: FracPoint[] = [];
|
|
390
|
+
for (let i = 0; i < coords.length; i += 2) {
|
|
391
|
+
out.push({ x: (coords[i] ?? 0) / w, y: (coords[i + 1] ?? 0) / h });
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const centerOf = (pts: FracPoint[]): FracPoint => {
|
|
397
|
+
const xs = pts.map((p) => p.x);
|
|
398
|
+
const ys = pts.map((p) => p.y);
|
|
399
|
+
return {
|
|
400
|
+
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
|
401
|
+
y: (Math.min(...ys) + Math.max(...ys)) / 2,
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const expectNear = (got: readonly FracPoint[], want: readonly FracPoint[]) => {
|
|
406
|
+
expect(got).toHaveLength(want.length);
|
|
407
|
+
got.forEach((p, i) => {
|
|
408
|
+
expect(p.x).toBeCloseTo(want[i]?.x ?? Number.NaN, 3);
|
|
409
|
+
expect(p.y).toBeCloseTo(want[i]?.y ?? Number.NaN, 3);
|
|
410
|
+
});
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
describe('a moved mark', () => {
|
|
414
|
+
const POINT: AnnotationInit = {
|
|
415
|
+
target: 'naive',
|
|
416
|
+
kind: 'point',
|
|
417
|
+
frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
|
|
418
|
+
points: [{ x: 0.25, y: 0.5 }],
|
|
419
|
+
};
|
|
420
|
+
const LINE: AnnotationInit = {
|
|
421
|
+
target: 'naive',
|
|
422
|
+
kind: 'line',
|
|
423
|
+
frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.4 },
|
|
424
|
+
points: [
|
|
425
|
+
{ x: 0.1, y: 0.6 },
|
|
426
|
+
{ x: 0.6, y: 0.2 },
|
|
427
|
+
],
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
it('draws a point where the store hit-tests it after update moves it', () => {
|
|
431
|
+
const store = makeStore();
|
|
432
|
+
const id = store.add(POINT);
|
|
433
|
+
store.update(id, { frac: { x: 0.75, y: 0.25, w: 0, h: 0 } });
|
|
434
|
+
|
|
435
|
+
expectNear([centerOf(drawn(store))], [{ x: 0.75, y: 0.25 }]);
|
|
436
|
+
expect(store.hitTest('naive', { x: 0.75, y: 0.25 }, 0.001).map((a) => a.id)).toEqual([id]);
|
|
437
|
+
expect(store.get(id)?.points).toEqual([{ x: 0.75, y: 0.25 }]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('carries a line and a stroke along with their bounds', () => {
|
|
441
|
+
for (const kind of ['line', 'arrow', 'stroke'] as const) {
|
|
442
|
+
const store = makeStore();
|
|
443
|
+
const id = store.add({ ...LINE, kind });
|
|
444
|
+
store.update(id, { frac: { x: 0.3, y: 0.3, w: 0.5, h: 0.4 } });
|
|
445
|
+
const moved = [
|
|
446
|
+
{ x: 0.3, y: 0.7 },
|
|
447
|
+
{ x: 0.8, y: 0.3 },
|
|
448
|
+
];
|
|
449
|
+
expectNear(drawn(store), moved);
|
|
450
|
+
expect(store.get(id)?.points).toEqual(moved);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('stretches stored vertices with a resized box', () => {
|
|
455
|
+
const store = makeStore();
|
|
456
|
+
const id = store.add(LINE);
|
|
457
|
+
store.update(id, { frac: { x: 0.1, y: 0.2, w: 0.25, h: 0.8 } });
|
|
458
|
+
expectNear(drawn(store), [
|
|
459
|
+
{ x: 0.1, y: 1 },
|
|
460
|
+
{ x: 0.35, y: 0.2 },
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('follows a pose written straight to the scene, the way the pane moves a mark', () => {
|
|
465
|
+
const store = makeStore();
|
|
466
|
+
const id = store.add(POINT);
|
|
467
|
+
const node = asNodeId(id.slice(id.lastIndexOf('/') + 1));
|
|
468
|
+
store.sceneFor('naive').setPose(node, { x: 0.5 * 256, y: 0.1 * 170, width: 0, height: 0 });
|
|
469
|
+
|
|
470
|
+
expectNear([centerOf(drawn(store))], [{ x: 0.5, y: 0.1 }]);
|
|
471
|
+
expect(store.get(id)?.points).toEqual([{ x: 0.5, y: 0.1 }]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('moves the bounds when only the points are patched', () => {
|
|
475
|
+
const store = makeStore();
|
|
476
|
+
const id = store.add(POINT);
|
|
477
|
+
store.update(id, { points: [{ x: 0.9, y: 0.9 }] });
|
|
478
|
+
|
|
479
|
+
expect(store.get(id)?.frac).toEqual({ x: 0.9, y: 0.9, w: 0, h: 0 });
|
|
480
|
+
expectNear([centerOf(drawn(store))], [{ x: 0.9, y: 0.9 }]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('reads points a store saved in content fractions, before they followed the bounds', () => {
|
|
484
|
+
const legacy = {
|
|
485
|
+
version: 1,
|
|
486
|
+
scenes: {
|
|
487
|
+
naive: {
|
|
488
|
+
version: 1,
|
|
489
|
+
systemLayers: [{ id: 'marks' }],
|
|
490
|
+
nodes: [
|
|
491
|
+
{
|
|
492
|
+
id: 'n1',
|
|
493
|
+
kind: 'leaf',
|
|
494
|
+
layer: 'marks',
|
|
495
|
+
pose: { x: 0.1 * 256, y: 0.2 * 170, width: 0.5 * 256, height: 0.4 * 170 },
|
|
496
|
+
data: { target: 'naive', kind: 'line', points: LINE.points },
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
const store = annotationsFromJSON(legacy, () => TARGETS);
|
|
503
|
+
expectNear(drawn(store), LINE.points ?? []);
|
|
504
|
+
expect(store.get('naive/n1')?.points).toEqual(LINE.points);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
298
508
|
describe('paintedMarks', () => {
|
|
299
509
|
it("hands over a target's marks in world units with their resolved style", () => {
|
|
300
510
|
let config: Record<string, string> = { angle: 'iso', shading: 'outline' };
|