@weasel-js/labkit 1.6.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
- package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
- package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
- package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
- package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
- package/dist/_dts/{useTrialState-CMaV6UD4.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 +6 -4
- package/dist/chrome/index.js +11 -10
- 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-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
- package/dist/chunk-CEGFXTBD.js.map +1 -0
- package/dist/chunk-CZZPXUOU.js +3 -0
- package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
- package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
- package/dist/chunk-E23Q6A3Y.js.map +1 -0
- package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
- package/dist/{chunk-GHGO52SO.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-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
- 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-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
- package/dist/chunk-GAHELLJT.js.map +1 -0
- package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
- package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
- package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
- 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-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
- 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-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
- 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-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
- package/dist/chunk-VYVGVBGY.js.map +1 -0
- package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
- package/dist/chunk-WLMDTI2Q.js.map +1 -0
- package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
- 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-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
- 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 +28 -13
- 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 +72 -487
- package/dist/index.js +603 -441
- 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 +20 -18
- 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 -2701
- package/dist/passthrough/weasel-ui.js +4 -3
- package/dist/primitives/index.d.ts +37 -2
- package/dist/primitives/index.js +9 -8
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +6 -6
- package/dist/styles.css +150 -126
- 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 +14 -13
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
- package/src/annotations/AnnotationOverlay.tsx +52 -33
- package/src/annotations/Annotations.less +0 -10
- package/src/annotations/Annotations.overlay.test.tsx +31 -0
- package/src/annotations/MarkList.tsx +4 -8
- 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 +76 -46
- package/src/annotations/paint.ts +65 -43
- package/src/annotations/store.test.ts +212 -2
- package/src/annotations/store.ts +130 -27
- package/src/annotations/svgNodes.test.ts +28 -19
- package/src/annotations/svgNodes.ts +6 -6
- package/src/annotations/types.ts +29 -10
- package/src/canvas/AGENTS.md +1 -0
- package/src/canvas/CameraInput.test.tsx +30 -0
- package/src/canvas/CameraInput.tsx +60 -48
- package/src/canvas/CanvasStack.tsx +11 -6
- package/src/canvas/CanvasStackContext.ts +4 -1
- package/src/canvas/LinkedCursor.less +1 -1
- package/src/canvas/Stage.test.tsx +33 -4
- package/src/canvas/Stage.tsx +9 -4
- package/src/canvas/cameraRegistry.test.ts +55 -0
- package/src/canvas/cameraRegistry.ts +87 -0
- package/src/canvas/cameraZoom.test.ts +46 -0
- package/src/canvas/cameraZoom.ts +24 -0
- package/src/canvas/index.ts +14 -2
- package/src/canvas/sharedScope.test.tsx +94 -0
- package/src/canvas/worldSpec.ts +3 -1
- package/src/chrome/ChromeRegions.stories.tsx +1 -0
- 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.less +0 -7
- package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
- package/src/chrome/regions/PaletteRegion.tsx +3 -4
- package/src/chrome/regions/SidebarRegion.less +12 -2
- 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.test.ts +2 -1
- package/src/config/builder.ts +18 -5
- package/src/config/fromConfigField.test.ts +14 -0
- package/src/config/fromConfigField.ts +18 -4
- package/src/config/path.ts +2 -5
- package/src/config/resolve.ts +4 -0
- package/src/config/types.ts +14 -9
- package/src/controls/ControlPanel.less +1 -1
- package/src/controls/ControlPanel.stories.tsx +3 -2
- package/src/controls/ControlPanel.test.tsx +98 -11
- package/src/controls/ControlPanel.tsx +61 -2
- 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 +16 -2
- package/src/instrument/types.ts +0 -6
- package/src/job/useJob.ts +14 -9
- package/src/lab/Lab.tsx +88 -73
- package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
- package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
- package/src/lab/LabFullChrome.stories.tsx +1 -0
- package/src/lab/LabHeader.test.tsx +24 -14
- package/src/lab/LabHeader.tsx +11 -12
- package/src/lab/LabShell.less +9 -17
- package/src/lab/LabSwitcher.browser.test.tsx +27 -0
- package/src/lab/LabSwitcher.less +8 -6
- package/src/lab/LabZoom.test.tsx +223 -0
- package/src/lab/LabZoom.tsx +170 -0
- package/src/lab/Workspace.tsx +48 -52
- package/src/lab/index.ts +1 -0
- 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/LoupeGestures.tsx +1 -1
- 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 +10 -8
- package/src/passthrough/weasel-ui.test.ts +2 -0
- package/src/passthrough/weasel-ui.ts +17 -5
- package/src/primitives/FloatingPanel.test.tsx +11 -0
- package/src/primitives/FloatingPanel.tsx +8 -7
- package/src/primitives/JobProgress.less +2 -1
- package/src/primitives/Legend.stories.tsx +1 -0
- package/src/primitives/Readout.browser.test.tsx +137 -0
- package/src/primitives/Readout.less +12 -0
- package/src/primitives/Readout.stories.tsx +58 -0
- package/src/primitives/Readout.test.tsx +93 -0
- package/src/primitives/Readout.tsx +56 -0
- package/src/primitives/Split.tsx +69 -41
- package/src/primitives/Toolbar.tsx +3 -3
- package/src/primitives/ZoomControl.less +1 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.stories.tsx +1 -0
- package/src/specimen/Specimen.tsx +2 -3
- package/src/state/Persistence.browser.test.tsx +47 -0
- package/src/state/store.test.ts +5 -1
- package/src/state/undock.ts +3 -0
- package/src/state/useOpenOnce.ts +20 -16
- package/src/state/usePersistedState.test.tsx +1 -1
- package/src/state/view.ts +1 -0
- package/src/styles.less +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/theme/Interstellar.stories.tsx +3 -1
- package/src/theme/base.less +21 -47
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.theme.json +10 -3
- package/src/trial/Trial.annotations.test.tsx +73 -0
- package/src/trial/Trial.less +0 -29
- package/src/trial/Trial.loupe.test.tsx +28 -11
- package/src/trial/Trial.tsx +85 -67
- package/src/trial/TrialChrome.tsx +27 -6
- package/src/trial/index.ts +2 -0
- package/src/trial/loupeSwitch.ts +15 -0
- package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
- package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
- package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
- package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
- package/dist/chunk-4TT3PNPR.js.map +0 -1
- package/dist/chunk-6FLXL3I7.js.map +0 -1
- package/dist/chunk-ASOK76ZH.js.map +0 -1
- package/dist/chunk-CGHF6RZV.js.map +0 -1
- package/dist/chunk-CQLQPQ4P.js.map +0 -1
- package/dist/chunk-DWMSB4CA.js.map +0 -1
- package/dist/chunk-HXXTULDN.js.map +0 -1
- package/dist/chunk-IJJUK4FU.js.map +0 -1
- package/dist/chunk-IRTGUQ5O.js.map +0 -1
- package/dist/chunk-KHK5FGER.js +0 -145
- package/dist/chunk-KHK5FGER.js.map +0 -1
- package/dist/chunk-NEXR37YJ.js.map +0 -1
- package/dist/chunk-O6FFFOHS.js.map +0 -1
- package/dist/chunk-QGMOA2YE.js.map +0 -1
- package/dist/chunk-RWNDDKOH.js.map +0 -1
- package/dist/chunk-S4NCI32V.js +0 -549
- package/dist/chunk-S4NCI32V.js.map +0 -1
- package/dist/chunk-SBXNQW4G.js +0 -3
- package/dist/chunk-SLAQPUI4.js.map +0 -1
- package/dist/chunk-TJ77A4A3.js.map +0 -1
- package/dist/chunk-TYN6MLXX.js +0 -13864
- package/dist/chunk-TYN6MLXX.js.map +0 -1
- package/dist/chunk-WPLAANH4.js.map +0 -1
- package/src/primitives/useRovingTabIndex.test.ts +0 -48
- package/src/primitives/useRovingTabIndex.ts +0 -88
- package/src/state/Persistence.stories.tsx +0 -75
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,32 +42,16 @@ describe('markCommands', () => {
|
|
|
39
42
|
expect(Math.max(...ys)).toBeCloseTo(POSE.y + POSE.height, 4);
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
it('runs a line between its two stored ends,
|
|
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
|
-
it('makes an arrow the same line,
|
|
52
|
-
const cmds = markCommands(mark('arrow', {
|
|
49
|
+
it('makes an arrow the same line, carrying an end marker', () => {
|
|
50
|
+
const cmds = markCommands(mark('arrow', { shape: ENDS }));
|
|
51
|
+
expect(cmds).toHaveLength(1);
|
|
53
52
|
const stroke = (cmds[0] as { stroke?: { markerEnd?: unknown } }).stroke;
|
|
54
53
|
expect(stroke?.markerEnd).toBe('arrow');
|
|
55
|
-
expect(anchors(
|
|
56
|
-
|
|
57
|
-
// The head has to be geometry, not just a field: `markerEnd` alone is
|
|
58
|
-
// inert outside the kit's own node painter, which is what shipped an
|
|
59
|
-
// arrow with no head.
|
|
60
|
-
expect(cmds).toHaveLength(2);
|
|
61
|
-
const head = anchors([cmds[1]]);
|
|
62
|
-
expect(head.length).toBeGreaterThan(0);
|
|
63
|
-
// Its vertex is the line's far end — computed, not eyeballed.
|
|
64
|
-
const near = (v: number, t: number) => Math.abs(v - t) < 1;
|
|
65
|
-
const xs = head.filter((_, i) => i % 2 === 0);
|
|
66
|
-
const ys = head.filter((_, i) => i % 2 === 1);
|
|
67
|
-
expect(xs.some((x, i) => near(x, 120) && near(ys[i] as number, 90))).toBe(true);
|
|
54
|
+
expect(anchors(cmds)).toEqual([35, 10, 80, 30]);
|
|
68
55
|
});
|
|
69
56
|
|
|
70
57
|
it('threads a freehand stroke through every point', () => {
|
|
@@ -73,15 +60,13 @@ describe('markCommands', () => {
|
|
|
73
60
|
{ x: 0.5, y: 0.5 },
|
|
74
61
|
{ x: 1, y: 0 },
|
|
75
62
|
];
|
|
76
|
-
expect(anchors(markCommands(mark('stroke', {
|
|
77
|
-
0, 0, 100, 50, 200, 0,
|
|
78
|
-
]);
|
|
63
|
+
expect(anchors(markCommands(mark('stroke', { shape: pts })))).toEqual([20, 10, 50, 30, 80, 10]);
|
|
79
64
|
});
|
|
80
65
|
|
|
81
|
-
it('falls back to the pose diagonal when a
|
|
82
|
-
// 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
|
|
83
68
|
// something at the right place, not vanish.
|
|
84
|
-
expect(anchors(markCommands(mark('line')
|
|
69
|
+
expect(anchors(markCommands(mark('line')))).toEqual([
|
|
85
70
|
POSE.x,
|
|
86
71
|
POSE.y,
|
|
87
72
|
POSE.x + POSE.width,
|
|
@@ -90,18 +75,18 @@ describe('markCommands', () => {
|
|
|
90
75
|
});
|
|
91
76
|
|
|
92
77
|
it('draws text at the pose, carrying the title', () => {
|
|
93
|
-
const [cmd] = markCommands(mark('text', { title: 'missing edge' })
|
|
78
|
+
const [cmd] = markCommands(mark('text', { title: 'missing edge' }));
|
|
94
79
|
expect(cmd).toMatchObject({ kind: 'text', x: POSE.x, y: POSE.y });
|
|
95
80
|
const runs = (cmd as { runs: { text: string }[] }).runs;
|
|
96
81
|
expect(runs.map((r) => r.text).join('')).toBe('missing edge');
|
|
97
82
|
});
|
|
98
83
|
|
|
99
84
|
it('draws an untitled text mark as nothing rather than an empty run', () => {
|
|
100
|
-
expect(markCommands(mark('text')
|
|
85
|
+
expect(markCommands(mark('text'))).toEqual([]);
|
|
101
86
|
});
|
|
102
87
|
|
|
103
|
-
it('draws a point as a small unfilled ring centered on its
|
|
104
|
-
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' } });
|
|
105
90
|
expect(cmds).toHaveLength(1);
|
|
106
91
|
expect((cmds[0] as { fill?: unknown }).fill).toBeUndefined();
|
|
107
92
|
const pts = anchors(cmds);
|
|
@@ -115,9 +100,56 @@ describe('markCommands', () => {
|
|
|
115
100
|
});
|
|
116
101
|
});
|
|
117
102
|
|
|
103
|
+
describe('a point mark at different zooms', () => {
|
|
104
|
+
const POINT = { pose: AT_50, data: { target: 't', kind: 'point' } as AnnotationData };
|
|
105
|
+
const radiusAt = (zoom: number) => {
|
|
106
|
+
const xs = anchors(markCommands(POINT, {}, { x: zoom, y: zoom })).filter((_, i) => i % 2 === 0);
|
|
107
|
+
return (Math.max(...xs) - Math.min(...xs)) / 2;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
it('sizes the ring in screen pixels, so its world radius scales inversely with zoom', () => {
|
|
111
|
+
expect(radiusAt(2)).toBeCloseTo(2, 4);
|
|
112
|
+
expect(radiusAt(4)).toBeCloseTo(1, 4);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps the ring round on screen under a non-uniform scale', () => {
|
|
116
|
+
const pts = anchors(markCommands(POINT, {}, { x: 2, y: 4 }));
|
|
117
|
+
const xs = pts.filter((_, i) => i % 2 === 0);
|
|
118
|
+
const ys = pts.filter((_, i) => i % 2 === 1);
|
|
119
|
+
expect((Math.max(...xs) - Math.min(...xs)) * 2).toBeCloseTo(
|
|
120
|
+
(Math.max(...ys) - Math.min(...ys)) * 4,
|
|
121
|
+
4,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('picking a point mark', () => {
|
|
127
|
+
let dispose: () => void = () => {};
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
dispose = registerNodeShape(POINT_MARK_SHAPE, { priority: 'high' });
|
|
130
|
+
});
|
|
131
|
+
afterEach(() => dispose());
|
|
132
|
+
|
|
133
|
+
const pose: WorldRect = { x: 50, y: 50, width: 0, height: 0 };
|
|
134
|
+
const node = {
|
|
135
|
+
id: 'p',
|
|
136
|
+
kind: 'leaf',
|
|
137
|
+
layer: 'marks',
|
|
138
|
+
pose,
|
|
139
|
+
data: { target: 't', kind: 'point' },
|
|
140
|
+
} as unknown as SceneNode<AnnotationData, 'marks', WorldRect>;
|
|
141
|
+
|
|
142
|
+
// Ring radius 4px plus half the 2px outline: the ring's outer edge is 5px out.
|
|
143
|
+
it.each([1, 4])('reaches the ring it draws and no further, at zoom %i', (zoom) => {
|
|
144
|
+
const at = (px: number) => shapeCoversPoint(node, pose, 50 + px / zoom, 50, { scale: zoom });
|
|
145
|
+
expect(at(4.9)).toBe(true);
|
|
146
|
+
expect(at(5.5)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
118
150
|
describe('markCommands styling', () => {
|
|
119
151
|
it('takes a status color over the default', () => {
|
|
120
|
-
const [cmd] = markCommands(mark('rect'),
|
|
152
|
+
const [cmd] = markCommands(mark('rect'), { color: '#30a46c' });
|
|
121
153
|
expect((cmd as { stroke?: { paint?: { color?: string } } }).stroke?.paint?.color).toBe(
|
|
122
154
|
'#30a46c',
|
|
123
155
|
);
|
|
@@ -125,21 +157,19 @@ describe('markCommands styling', () => {
|
|
|
125
157
|
|
|
126
158
|
it('dashes a stale mark rather than hiding it', () => {
|
|
127
159
|
// A stale mark still describes something; dropping it would lose it.
|
|
128
|
-
const [fresh] = markCommands(mark('rect')
|
|
129
|
-
const [stale] = markCommands(mark('rect'),
|
|
160
|
+
const [fresh] = markCommands(mark('rect'));
|
|
161
|
+
const [stale] = markCommands(mark('rect'), { stale: true });
|
|
130
162
|
expect((fresh as { stroke?: { dash?: number[] } }).stroke?.dash).toBeUndefined();
|
|
131
163
|
expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
|
|
132
164
|
});
|
|
133
165
|
|
|
134
166
|
it('dashes a stale point ring like any other mark', () => {
|
|
135
|
-
const [ring] = markCommands(mark('point', {
|
|
136
|
-
stale: true,
|
|
137
|
-
});
|
|
167
|
+
const [ring] = markCommands(mark('point'), { stale: true });
|
|
138
168
|
expect((ring as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
|
|
139
169
|
});
|
|
140
170
|
|
|
141
171
|
it("colors a text mark's glyphs too, not only the outlines", () => {
|
|
142
|
-
const [cmd] = markCommands(mark('text', { title: 'x' }),
|
|
172
|
+
const [cmd] = markCommands(mark('text', { title: 'x' }), { color: '#30a46c' });
|
|
143
173
|
const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
|
|
144
174
|
expect(runs[0]?.fill?.color).toBe('#30a46c');
|
|
145
175
|
});
|
package/src/annotations/paint.ts
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
|
-
import type { DrawCommand, Path, Stroke } from '@weasel-js/core';
|
|
1
|
+
import type { DrawCommand, NodeShapeEntry, Path, Stroke, View } from '@weasel-js/core';
|
|
2
2
|
import {
|
|
3
3
|
ellipsePath,
|
|
4
|
+
HANDLE_BASE_PX,
|
|
4
5
|
linePath,
|
|
5
|
-
|
|
6
|
+
meanScale,
|
|
6
7
|
PathBuilder,
|
|
8
|
+
pxExtent,
|
|
7
9
|
rectPath,
|
|
8
10
|
textCommand,
|
|
9
11
|
} from '@weasel-js/core';
|
|
10
|
-
import type
|
|
11
|
-
import type { AnnotationData
|
|
12
|
+
import { boxOf, fromShape, type WorldRect } from './frac';
|
|
13
|
+
import type { AnnotationData } from './types';
|
|
12
14
|
|
|
13
15
|
/** One loud color, not a themed one: a mark sits over the instrument's own
|
|
14
16
|
* picture and has to be legible against whatever that picture is. */
|
|
15
17
|
const MARK_COLOR = '#e5484d';
|
|
16
|
-
/** World units, so a mark thickens with the picture it annotates
|
|
17
|
-
* marker geometry can be resolved here, which needs a resolved width. */
|
|
18
|
+
/** World units, so a mark thickens with the picture it annotates. */
|
|
18
19
|
const MARK_WIDTH = 2;
|
|
19
20
|
const TEXT_SIZE = 14;
|
|
20
|
-
const
|
|
21
|
+
const STALE_DASH = [6, 4];
|
|
22
|
+
/** Screen pixels: a point marks a place rather than a region of the picture,
|
|
23
|
+
* so it holds its size like a selection handle. */
|
|
24
|
+
const POINT_RADIUS_PX = HANDLE_BASE_PX / 2;
|
|
25
|
+
const UNIT_SCALE: View['scale'] = { x: 1, y: 1 };
|
|
21
26
|
|
|
22
27
|
/** The subset of a mark's scene node this needs: where it is, and what it is. */
|
|
23
28
|
export interface PaintableMark {
|
|
@@ -36,16 +41,11 @@ export interface MarkStyle {
|
|
|
36
41
|
stale?: boolean;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/** A mark's stored vertices in world units, or the pose's diagonal — a stored
|
|
45
|
-
* mark whose `points` did not survive still has to draw somewhere. */
|
|
46
|
-
function vertices(m: PaintableMark, content: { w: number; h: number }): { x: number; y: number }[] {
|
|
47
|
-
const stored = m.data.points;
|
|
48
|
-
if (stored && stored.length >= 2) return stored.map((p) => toWorld(p, content));
|
|
44
|
+
/** A mark's stored vertices placed in its pose, or the pose's diagonal — a
|
|
45
|
+
* stored mark whose `shape` did not survive still has to draw somewhere. */
|
|
46
|
+
function vertices(m: PaintableMark): { x: number; y: number }[] {
|
|
47
|
+
const stored = m.data.shape;
|
|
48
|
+
if (stored && stored.length >= 2) return fromShape(stored, boxOf(m.pose));
|
|
49
49
|
const { x, y, width, height } = m.pose;
|
|
50
50
|
return [
|
|
51
51
|
{ x, y },
|
|
@@ -65,14 +65,18 @@ function polyline(points: { x: number; y: number }[]): Path {
|
|
|
65
65
|
/**
|
|
66
66
|
* What one mark draws, in its target's world.
|
|
67
67
|
*
|
|
68
|
-
* Pure: a node
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* Pure: a node in, draw commands out, in its target's world. Geometry that a
|
|
69
|
+
* bounding box cannot describe — a line's ends, a stroke's path — comes from
|
|
70
|
+
* `data.shape`, placed in the pose.
|
|
71
|
+
*
|
|
72
|
+
* `scale` is the view's, world to screen. Only a point reads it — its ring is
|
|
73
|
+
* sized in screen pixels — and the default draws that ring as though a world
|
|
74
|
+
* unit were a pixel.
|
|
71
75
|
*/
|
|
72
76
|
export function markCommands(
|
|
73
77
|
m: PaintableMark,
|
|
74
|
-
content: { w: number; h: number },
|
|
75
78
|
style: MarkStyle = {},
|
|
79
|
+
scale: View['scale'] = UNIT_SCALE,
|
|
76
80
|
): DrawCommand[] {
|
|
77
81
|
const color = style.color ?? MARK_COLOR;
|
|
78
82
|
const stroke: Stroke = {
|
|
@@ -80,7 +84,7 @@ export function markCommands(
|
|
|
80
84
|
width: MARK_WIDTH,
|
|
81
85
|
cap: 'round',
|
|
82
86
|
join: 'round',
|
|
83
|
-
...(style.stale ? { dash:
|
|
87
|
+
...(style.stale ? { dash: STALE_DASH } : {}),
|
|
84
88
|
};
|
|
85
89
|
|
|
86
90
|
switch (m.data.kind) {
|
|
@@ -91,34 +95,32 @@ export function markCommands(
|
|
|
91
95
|
case 'ellipse':
|
|
92
96
|
return [{ kind: 'path', path: ellipsePath(m.pose), stroke }];
|
|
93
97
|
case 'line': {
|
|
94
|
-
const [a, b] = vertices(m
|
|
98
|
+
const [a, b] = vertices(m);
|
|
95
99
|
return [{ kind: 'path', path: linePath(a, b), stroke }];
|
|
96
100
|
}
|
|
97
101
|
case 'arrow': {
|
|
98
|
-
const [a, b] = vertices(m
|
|
102
|
+
const [a, b] = vertices(m);
|
|
99
103
|
// The spec's arrow: a line carrying an end marker, not its own geometry.
|
|
100
|
-
|
|
101
|
-
// something turns it into geometry, and only the kit's own node painter
|
|
102
|
-
// does that, for the nodes it owns.
|
|
103
|
-
const marked: Stroke = { ...stroke, markerEnd: 'arrow' };
|
|
104
|
-
const path = linePath(a, b);
|
|
105
|
-
return [
|
|
106
|
-
{ kind: 'path', path, stroke: marked },
|
|
107
|
-
...markerDrawCommands(path, marked, MARK_WIDTH, undefined),
|
|
108
|
-
];
|
|
104
|
+
return [{ kind: 'path', path: linePath(a, b), stroke: { ...stroke, markerEnd: 'arrow' } }];
|
|
109
105
|
}
|
|
110
106
|
case 'stroke':
|
|
111
|
-
return [{ kind: 'path', path: polyline(vertices(m
|
|
107
|
+
return [{ kind: 'path', path: polyline(vertices(m)), stroke }];
|
|
112
108
|
case 'point': {
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const ring = {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
109
|
+
const c = { x: m.pose.x, y: m.pose.y };
|
|
110
|
+
const r = pxExtent(POINT_RADIUS_PX, scale);
|
|
111
|
+
const ring = { x: c.x - r.x, y: c.y - r.y, width: 2 * r.x, height: 2 * r.y };
|
|
112
|
+
const px = 1 / meanScale(scale);
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
kind: 'path',
|
|
116
|
+
path: ellipsePath(ring),
|
|
117
|
+
stroke: {
|
|
118
|
+
...stroke,
|
|
119
|
+
width: MARK_WIDTH * px,
|
|
120
|
+
...(stroke.dash ? { dash: stroke.dash.map((d) => d * px) } : {}),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
];
|
|
122
124
|
}
|
|
123
125
|
case 'text': {
|
|
124
126
|
const text = m.data.title;
|
|
@@ -140,3 +142,23 @@ export function markCommands(
|
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What picking reads for a point mark: its pose is zero-size, so without this
|
|
148
|
+
* the pointer slop alone decides how close counts, whatever the ring's size.
|
|
149
|
+
* Registered by the overlay, which is the one place marks are picked.
|
|
150
|
+
*/
|
|
151
|
+
export const POINT_MARK_SHAPE: NodeShapeEntry<AnnotationData, WorldRect> = {
|
|
152
|
+
id: 'labkit:point-mark',
|
|
153
|
+
matches: (node) =>
|
|
154
|
+
node.layer === 'marks' && node.data?.kind === 'point' && typeof node.data.target === 'string',
|
|
155
|
+
// The overlay paints through its own draw callback. This is for a scene
|
|
156
|
+
// drawn without one.
|
|
157
|
+
paint: (node, pose) => markCommands({ pose, data: node.data }),
|
|
158
|
+
silhouette: (_node, pose) => rectPath(pose.x, pose.y, 0, 0),
|
|
159
|
+
// Filled, so a click inside the ring lands on it as it would on a handle.
|
|
160
|
+
ink: (_node, _pose, ctx) => {
|
|
161
|
+
const s = ctx?.scale ?? 1;
|
|
162
|
+
return { filled: true, outset: (POINT_RADIUS_PX + MARK_WIDTH / 2) / s, inset: 0 };
|
|
163
|
+
},
|
|
164
|
+
};
|