@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,222 @@
|
|
|
1
|
+
import type { RefObject } from 'react';
|
|
2
|
+
import type { ViewTransform } from '../instrument/types';
|
|
3
|
+
import type { MarkScene } from './store';
|
|
4
|
+
|
|
5
|
+
/** A point in fractions of a target's content box. */
|
|
6
|
+
export interface FracPoint {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A rectangle in fractions of a target's content box.
|
|
12
|
+
*
|
|
13
|
+
* Fractions rather than pixels because a mark must survive a change of render
|
|
14
|
+
* resolution: the picture gets bigger, the mark stays on the same feature. */
|
|
15
|
+
export interface FracRect {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
w: number;
|
|
19
|
+
h: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What shape a mark is. The kinds map onto weasel's own tools; an arrow is a
|
|
23
|
+
* line carrying an end marker, not a separate geometry. */
|
|
24
|
+
export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text';
|
|
25
|
+
|
|
26
|
+
/** One selectable status in an instrument's meaning tier. */
|
|
27
|
+
export interface AnnotationStatus {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
/** What a mark in this status is drawn in. Omitted, it takes the default
|
|
31
|
+
* mark colour — a status is allowed to be a label and nothing more. */
|
|
32
|
+
color?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The optional meaning tier: what a mark means, as opposed to where it is.
|
|
36
|
+
* An instrument declaring it gets labkit's own chrome for the vocabulary;
|
|
37
|
+
* one that omits it keeps `meta` and owns meaning entirely. */
|
|
38
|
+
export interface AnnotationMeaning {
|
|
39
|
+
statuses?: readonly AnnotationStatus[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What labkit keeps in a mark's scene-node data. */
|
|
43
|
+
export interface AnnotationData {
|
|
44
|
+
target: string;
|
|
45
|
+
kind: AnnotationKind;
|
|
46
|
+
/** Vertices for the kinds a bounding box cannot describe — a line's two
|
|
47
|
+
* ends, a stroke's path. In fractions, like the bounds. */
|
|
48
|
+
points?: readonly FracPoint[];
|
|
49
|
+
title?: string;
|
|
50
|
+
status?: string;
|
|
51
|
+
tags?: readonly string[];
|
|
52
|
+
meta?: unknown;
|
|
53
|
+
/** The target's `positionDependsOn` values when the mark was made. Compared
|
|
54
|
+
* against the live config to answer whether the mark still describes the
|
|
55
|
+
* same picture. */
|
|
56
|
+
seen?: Readonly<Record<string, unknown>>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A mark, as the store reports it. `id` is `<target>/<node>`: one scene per
|
|
60
|
+
* target means a node id is only unique within one. */
|
|
61
|
+
export interface Annotation extends AnnotationData {
|
|
62
|
+
id: string;
|
|
63
|
+
/** Bounds in fractions of the target's content box. */
|
|
64
|
+
frac: FracRect;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A new mark, before the store gives it an id and dates it. */
|
|
68
|
+
export interface AnnotationInit {
|
|
69
|
+
target: string;
|
|
70
|
+
kind: AnnotationKind;
|
|
71
|
+
frac: FracRect;
|
|
72
|
+
points?: readonly FracPoint[];
|
|
73
|
+
title?: string;
|
|
74
|
+
status?: string;
|
|
75
|
+
tags?: readonly string[];
|
|
76
|
+
meta?: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Filters, ANDed. A mark matches `tags` when it carries every tag listed. */
|
|
80
|
+
export interface AnnotationQuery {
|
|
81
|
+
target?: string;
|
|
82
|
+
kind?: AnnotationKind;
|
|
83
|
+
status?: string;
|
|
84
|
+
tags?: readonly string[];
|
|
85
|
+
where?: (a: Annotation) => boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What a mark's meaning can be patched to. Geometry moves through `frac` /
|
|
89
|
+
* `points`; everything else is the meaning tier. */
|
|
90
|
+
export type AnnotationPatch = Partial<
|
|
91
|
+
Pick<Annotation, 'frac' | 'points' | 'title' | 'status' | 'tags' | 'meta'>
|
|
92
|
+
>;
|
|
93
|
+
|
|
94
|
+
/** A target's own picture, handed over for an export to draw marks on top of.
|
|
95
|
+
* labkit cannot rasterize it: it is the consumer's DOM. `svg` is the one that
|
|
96
|
+
* keeps the export vector all the way through. */
|
|
97
|
+
export type CaptureSource =
|
|
98
|
+
| { kind: 'svg'; markup: string }
|
|
99
|
+
| { kind: 'image'; src: string }
|
|
100
|
+
| { kind: 'canvas'; canvas: HTMLCanvasElement };
|
|
101
|
+
|
|
102
|
+
/** What an export produces, and at what resolution. */
|
|
103
|
+
export interface CaptureOptions {
|
|
104
|
+
/** `png` rasterizes; `svg` stays vector, which needs the base to be one too
|
|
105
|
+
* — a raster base embeds as an `<image>`. Default `png`. */
|
|
106
|
+
format?: 'png' | 'svg';
|
|
107
|
+
/** Output pixels per unit of the target's content box. Default 2. */
|
|
108
|
+
scale?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A finished export. `width`/`height` are the output's, not the content
|
|
112
|
+
* box's — `content × scale`. */
|
|
113
|
+
export interface CaptureResult {
|
|
114
|
+
target: string;
|
|
115
|
+
blob: Blob;
|
|
116
|
+
format: 'png' | 'svg';
|
|
117
|
+
width: number;
|
|
118
|
+
height: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What the store needs to know about a target: enough to convert a position
|
|
122
|
+
* and to date a mark. `AnnotationTarget` adds what only the overlay reads. */
|
|
123
|
+
export interface AnnotationTargetInfo {
|
|
124
|
+
id: string;
|
|
125
|
+
/** Intrinsic content size in CSS pixels at zoom 1 — the box fractions are
|
|
126
|
+
* fractions *of*, and the target's world. */
|
|
127
|
+
content: { w: number; h: number };
|
|
128
|
+
/** Config keys whose change means a stored position no longer refers to the
|
|
129
|
+
* same picture. labkit snapshots and compares them without knowing what any
|
|
130
|
+
* of them mean. */
|
|
131
|
+
positionDependsOn?: readonly string[];
|
|
132
|
+
/** The target's own picture, for an export to draw marks over. A target
|
|
133
|
+
* declaring none exports its marks on transparency, which fails visibly
|
|
134
|
+
* rather than producing a blank brick.
|
|
135
|
+
*
|
|
136
|
+
* Here rather than on `AnnotationTarget` because the store is what calls
|
|
137
|
+
* it: `ref` and `view` are the React-shaped half only the overlay reads. */
|
|
138
|
+
base?: () => CaptureSource | Promise<CaptureSource>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One region of an instrument that accepts marks. */
|
|
142
|
+
export interface AnnotationTarget extends AnnotationTargetInfo {
|
|
143
|
+
/** The element the overlay tracks and takes input from. */
|
|
144
|
+
ref: RefObject<HTMLElement | null>;
|
|
145
|
+
/** The pane's camera, mirrored so marks pan and zoom with what they mark. */
|
|
146
|
+
view?: ViewTransform;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Where an instrument keeps its own marks. Declaring this means labkit never
|
|
150
|
+
* writes its trial slot — for an instrument whose marks belong in a format it
|
|
151
|
+
* already owns. Both halves are called outside React; `save` is already
|
|
152
|
+
* debounced by the time it arrives. */
|
|
153
|
+
export interface AnnotationStorage {
|
|
154
|
+
load: () => SerializedAnnotations | null | undefined;
|
|
155
|
+
save: (doc: SerializedAnnotations) => void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Declares that an instrument accepts marks: which regions take them,
|
|
159
|
+
* optionally what a mark is allowed to mean, and optionally where they live. */
|
|
160
|
+
export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
161
|
+
targets: (state: TS, config: TC) => readonly AnnotationTarget[];
|
|
162
|
+
meaning?: AnnotationMeaning;
|
|
163
|
+
storage?: AnnotationStorage;
|
|
164
|
+
/** Fires after every finished export, labkit's own chrome included. A
|
|
165
|
+
* notification, not an interception: a host wanting its own flow calls
|
|
166
|
+
* `capture()` from its own UI, which is the surface the chrome uses. */
|
|
167
|
+
onCapture?: (result: CaptureResult) => void;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A persisted mark set. Versioned by this arc rather than by labkit's
|
|
171
|
+
* document migrations, which only ever reach top-level document sections and
|
|
172
|
+
* never into a trial's state. */
|
|
173
|
+
export interface SerializedAnnotations {
|
|
174
|
+
version: 1;
|
|
175
|
+
/** One serialized scene per target that has ever held a mark. */
|
|
176
|
+
scenes: Record<string, unknown>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Everything a host can ask or tell labkit about the marks on its targets. */
|
|
180
|
+
export interface AnnotationsApi {
|
|
181
|
+
/** The scene a target's marks live in, created on first ask. One per target
|
|
182
|
+
* because a pane's hit-test, marquee and paint walk the whole scene they
|
|
183
|
+
* are given: a shared one would put a neighbour's marks under the pointer. */
|
|
184
|
+
sceneFor(target: string): MarkScene;
|
|
185
|
+
/** The targets the instrument declares, in declaration order. Chrome needs
|
|
186
|
+
* the list and cannot get it from the capability, which wants instrument
|
|
187
|
+
* state the chrome context does not carry. */
|
|
188
|
+
targets(): readonly AnnotationTargetInfo[];
|
|
189
|
+
get(id: string): Annotation | undefined;
|
|
190
|
+
/** Every mark matching the filters, in scene order. Omit `q` for all. */
|
|
191
|
+
query(q?: AnnotationQuery): Annotation[];
|
|
192
|
+
/** Marks whose bounds contain `pt`, topmost first. `tol` widens the hit in
|
|
193
|
+
* fractions, so a hairline mark stays reachable. */
|
|
194
|
+
hitTest(target: string, pt: FracPoint, tol?: number): Annotation[];
|
|
195
|
+
/** Marks wholly inside `box` — a marquee, not a brush. */
|
|
196
|
+
within(target: string, box: FracRect): Annotation[];
|
|
197
|
+
/** Whether `a`'s position still describes the picture `config` produces. */
|
|
198
|
+
isStale(a: Annotation, config: unknown): boolean;
|
|
199
|
+
/** Fires after every mutation. No delta: the scene does not diff, so
|
|
200
|
+
* re-query rather than expecting a change payload. */
|
|
201
|
+
subscribe(fn: () => void): () => void;
|
|
202
|
+
|
|
203
|
+
/** Whether the last mark change on any target can be taken back. Weasel
|
|
204
|
+
* history is the authority; this only decides *which* target's. */
|
|
205
|
+
canUndo(): boolean;
|
|
206
|
+
canRedo(): boolean;
|
|
207
|
+
/** Take back the most recent mark change, wherever it was made. */
|
|
208
|
+
undo(): boolean;
|
|
209
|
+
redo(): boolean;
|
|
210
|
+
|
|
211
|
+
/** Export a target's picture with its marks on it. Rejects on an id the
|
|
212
|
+
* instrument does not declare. */
|
|
213
|
+
capture(target: string, opts?: CaptureOptions): Promise<CaptureResult>;
|
|
214
|
+
|
|
215
|
+
add(init: AnnotationInit, config?: unknown): string;
|
|
216
|
+
update(id: string, patch: AnnotationPatch): void;
|
|
217
|
+
setMeta(id: string, meta: unknown): void;
|
|
218
|
+
remove(id: string): void;
|
|
219
|
+
|
|
220
|
+
/** A JSON-safe snapshot for `record.state`. */
|
|
221
|
+
toJSON(): SerializedAnnotations;
|
|
222
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { fitView, fromWeaselView, toWeaselView } from './view';
|
|
3
|
+
|
|
4
|
+
describe('fitView', () => {
|
|
5
|
+
it("scales a target's content box to the box it is drawn in", () => {
|
|
6
|
+
expect(fitView({ w: 256, h: 170 }, { w: 512, h: 340 })).toEqual({
|
|
7
|
+
zoom: 2,
|
|
8
|
+
pan: { x: 0, y: 0 },
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('takes the smaller axis, so nothing is drawn past the pane', () => {
|
|
13
|
+
expect(fitView({ w: 256, h: 170 }, { w: 512, h: 170 })).toEqual({
|
|
14
|
+
zoom: 1,
|
|
15
|
+
pan: { x: 0, y: 0 },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reads zoom 1 from a degenerate content box rather than Infinity', () => {
|
|
20
|
+
expect(fitView({ w: 0, h: 0 }, { w: 512, h: 340 })).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('view conversion', () => {
|
|
25
|
+
it("round-trips a target's camera through weasel's shape", () => {
|
|
26
|
+
const v = { zoom: 1.5, pan: { x: -12, y: 40 } };
|
|
27
|
+
expect(fromWeaselView(toWeaselView(v))).toEqual(v);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("puts the pan in weasel's translation and the zoom in both scale axes", () => {
|
|
31
|
+
expect(toWeaselView({ zoom: 2, pan: { x: 10, y: -5 } })).toEqual({
|
|
32
|
+
x: 10,
|
|
33
|
+
y: -5,
|
|
34
|
+
scale: { x: 2, y: 2 },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { View } from '@weasel-js/core';
|
|
2
|
+
import type { ViewTransform } from '../instrument/types';
|
|
3
|
+
|
|
4
|
+
/** A target's world in CSS pixels: the box its fractions are fractions of. */
|
|
5
|
+
export interface ContentSize {
|
|
6
|
+
w: number;
|
|
7
|
+
h: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A target's drawn box on the surface, in CSS pixels. */
|
|
11
|
+
export interface PaneSize {
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** labkit's camera in weasel's shape. Zoom is uniform, so it lands on both
|
|
17
|
+
* scale axes; pan is weasel's translation unchanged. */
|
|
18
|
+
export function toWeaselView(v: ViewTransform): View {
|
|
19
|
+
return { x: v.pan.x, y: v.pan.y, scale: { x: v.zoom, y: v.zoom } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The inverse. A weasel view with unequal axis scales collapses to its x —
|
|
23
|
+
* labkit's camera has one zoom and cannot hold the other. */
|
|
24
|
+
export function fromWeaselView(v: View): ViewTransform {
|
|
25
|
+
return { zoom: v.scale.x, pan: { x: v.x, y: v.y } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The camera a target with none of its own draws through: its content box
|
|
30
|
+
* scaled to fit the box it is drawn in, anchored at the origin.
|
|
31
|
+
*
|
|
32
|
+
* The spec calls the camera "a plain scale" at zoom 1; zoom 1 is only right
|
|
33
|
+
* when the pane happens to be the content's size, and a mark drawn on a
|
|
34
|
+
* scaled-down pane otherwise lands outside it.
|
|
35
|
+
*/
|
|
36
|
+
export function fitView(content: ContentSize, pane: PaneSize): ViewTransform {
|
|
37
|
+
const zoom =
|
|
38
|
+
content.w > 0 && content.h > 0 ? Math.min(pane.w / content.w, pane.h / content.h) : 1;
|
|
39
|
+
return { zoom: Number.isFinite(zoom) && zoom > 0 ? zoom : 1, pan: { x: 0, y: 0 } };
|
|
40
|
+
}
|
package/src/canvas/AGENTS.md
CHANGED
|
@@ -18,7 +18,9 @@ into a camera is the host's job.
|
|
|
18
18
|
| `CanvasStackContext.ts` | React context exposing the current `view` to descendants |
|
|
19
19
|
| `useLayerScheduler.ts` | DPR-aware rAF scheduler; redraws dirty layers on view/state changes |
|
|
20
20
|
| `usePanZoom.ts` | Pointer + wheel handlers that mutate `view` via `onViewChange` |
|
|
21
|
+
| `camera.ts` | `zoomAt` (fixed-point zoom) and `centerOn` (put a world point at a viewport's middle) |
|
|
21
22
|
| `canvasCoords.ts` | Pure `screenToWorld` / `worldToScreen` helpers |
|
|
23
|
+
| `worldSpec.ts` | The instrument's declared coordinate system, and the camera derived from it |
|
|
22
24
|
| `CanvasStack.less` | Container + canvas + overlay positioning |
|
|
23
25
|
|
|
24
26
|
## Props (`<CanvasStack>`)
|
|
@@ -28,6 +30,8 @@ interface CanvasStackProps {
|
|
|
28
30
|
layers: CanvasLayerDescriptor[]; // { id, visible, render(ctx, view) }
|
|
29
31
|
view: ViewTransform; // { zoom, pan: { x, y } }
|
|
30
32
|
onViewChange: (v: ViewTransform) => void;
|
|
33
|
+
worldSpec?: WorldSpec; // origin + y direction; default top-left, y down
|
|
34
|
+
onResize?: (size: ViewportSize) => void;
|
|
31
35
|
minZoom?: number; // default 0.1, forwarded to usePanZoom
|
|
32
36
|
maxZoom?: number; // default 32, forwarded to usePanZoom
|
|
33
37
|
width?: number | string; // default '100%'
|
|
@@ -51,6 +55,7 @@ A layer is defined by the instrument's `canvas.layers[]` (type `CanvasLayer`), t
|
|
|
51
55
|
|
|
52
56
|
```ts
|
|
53
57
|
canvas: {
|
|
58
|
+
worldSpec: { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }, // optional
|
|
54
59
|
layers: [
|
|
55
60
|
{
|
|
56
61
|
id: 'grid',
|
|
@@ -60,9 +65,9 @@ canvas: {
|
|
|
60
65
|
}
|
|
61
66
|
```
|
|
62
67
|
|
|
63
|
-
`draw` receives the context with the camera already applied, so its coordinates are world coordinates — `Trial.tsx`
|
|
68
|
+
`draw` receives the context with the camera already applied, so its coordinates are world coordinates — `Trial.tsx` calls `applyCamera(ctx, view, frame)` before calling it. `zoom` is still passed so a layer can keep line widths and handle sizes from growing: divide by it (`ctx.lineWidth = 1 / zoom`).
|
|
64
69
|
|
|
65
|
-
That is the difference between the two layer types. The lower-level `CanvasLayerDescriptor.render(ctx, view)` gets the raw view and an untransformed context, which is what screen-space chrome wants.
|
|
70
|
+
That is the difference between the two layer types. The lower-level `CanvasLayerDescriptor.render(ctx, view, frame)` gets the raw view, the resolved frame and an untransformed context, which is what screen-space chrome wants.
|
|
66
71
|
|
|
67
72
|
Layer order in the array = paint order (first drawn = bottom).
|
|
68
73
|
|
|
@@ -70,17 +75,62 @@ Layer order in the array = paint order (first drawn = bottom).
|
|
|
70
75
|
|
|
71
76
|
The `children` prop renders in `.lk-canvas-stack__overlay`, which is `position: absolute; inset: 0; pointer-events: none`. Direct children re-enable pointer events. Use this for HUDs, scale indicators, or interactive overlays that don't belong in a canvas layer.
|
|
72
77
|
|
|
78
|
+
## The world spec
|
|
79
|
+
|
|
80
|
+
An instrument's world does not have to be labkit's. `CanvasCapability.worldSpec`
|
|
81
|
+
declares two things, and everything that maps between world and screen reads
|
|
82
|
+
them:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
worldSpec: { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' } // centred, y upward
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`origin` is a **fraction of the viewport** — where world (0,0) sits at `pan`
|
|
89
|
+
zero — so "centre" is `0.5` and needs no knowledge of the canvas size. `yAxis`
|
|
90
|
+
is `'down'` (default) or `'up'`. Omitting the spec entirely gives the original
|
|
91
|
+
convention: origin at the element's top-left, y downward.
|
|
92
|
+
|
|
93
|
+
`resolveFrame(spec, size)` turns a spec plus a measured viewport into a
|
|
94
|
+
`WorldFrame` — `{ originPx, yDir }` — which is what the rest of the directory
|
|
95
|
+
actually consumes. `CanvasStack` owns the measurement, so it owns the frame and
|
|
96
|
+
hands it to `usePanZoom`, to the scheduler, and to every `render`.
|
|
97
|
+
|
|
98
|
+
**Every world↔screen path must go through the frame.** There are four, and each
|
|
99
|
+
one is a silent wrong answer if it is missed: `worldToScreen` / `screenToWorld`,
|
|
100
|
+
the camera (`applyCamera`), the wheel anchor in `usePanZoom`, and
|
|
101
|
+
`DragDropRuntime`'s drop position. A miss produces geometry that is off by a
|
|
102
|
+
constant, or a wheel that drifts — never an error.
|
|
103
|
+
|
|
73
104
|
## Coordinate conversions
|
|
74
105
|
|
|
75
|
-
- `screenToWorld({x, y}, view)` → world coords given a CSS-pixel offset relative to the container's top-left
|
|
76
|
-
- `worldToScreen({x, y}, view)` → inverse
|
|
106
|
+
- `screenToWorld({x, y}, view, frame?)` → world coords given a CSS-pixel offset relative to the container's top-left
|
|
107
|
+
- `worldToScreen({x, y}, view, frame?)` → inverse
|
|
108
|
+
- `applyCamera(ctx, view, frame?)` → puts a 2D context into world coordinates; the exact inverse of `screenToWorld`
|
|
77
109
|
|
|
78
|
-
|
|
110
|
+
All pure, and all default to `DEFAULT_FRAME` (top-left, y down).
|
|
79
111
|
|
|
80
112
|
## Pan/zoom behavior
|
|
81
113
|
|
|
82
114
|
Implemented in `usePanZoom`. Mouse-wheel zoom is anchored at the cursor; primary-button drag pans. `isDragging()` is exposed so consumers can suppress click handlers during a pan.
|
|
83
115
|
|
|
116
|
+
The anchor is taken **in frame space** (`cursor - frame.originPx`), because
|
|
117
|
+
`pan` is measured from the frame's origin. Anchoring at the raw cursor instead
|
|
118
|
+
drifts by `(1 - ratio) * originPx` on every wheel step for any frame that moves
|
|
119
|
+
the origin — around 180x110 px per step on a centred 1430x870 canvas, with no
|
|
120
|
+
error raised. The anchoring itself is `zoomAt` in `camera.ts`, which the loupe's wheel also
|
|
121
|
+
uses — one fixed-point zoom rather than a copy per caller. Do not reach for
|
|
122
|
+
`zoomAt` from `@weasel-js/core` instead: that one clamps
|
|
123
|
+
`min(max, max(min, scale * factor))` per axis with positive defaults, so a y-up
|
|
124
|
+
view (`scale.y` negative) comes back at `+0.1` — flipped and collapsed. labkit's
|
|
125
|
+
holds one scalar zoom and keeps the y direction in the frame, so it has no such
|
|
126
|
+
axis to invert.
|
|
127
|
+
|
|
128
|
+
`CanvasCapability.initialView` may be a function of the viewport size instead of
|
|
129
|
+
a literal. labkit then leaves the trial's view `null` until the canvas is first
|
|
130
|
+
measured and places it from `onResize` — so an instrument framing content it can
|
|
131
|
+
only size against the viewport does not need a "have I centred yet" sentinel of
|
|
132
|
+
its own. Reset nulls the view again, which re-frames.
|
|
133
|
+
|
|
84
134
|
Zoom is clamped to `[minZoom, maxZoom]` (defaults 0.1 / 32), settable via `CanvasStack`'s props and, for an instrument's own canvas, `CanvasCapability.minZoom` / `maxZoom`. The clamp always widens to admit whatever zoom the canvas opened at — an instrument declaring `initialView.zoom: 1600` with the default range is not clamped down to 32 on the first wheel event.
|
|
85
135
|
|
|
86
136
|
## Testing notes
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
2
2
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { CanvasStack } from './CanvasStack';
|
|
4
4
|
import type { CanvasLayerDescriptor } from './useLayerScheduler';
|
|
@@ -79,4 +79,30 @@ describe('<CanvasStack>', () => {
|
|
|
79
79
|
);
|
|
80
80
|
expect(getByText('overlay-content')).toBeInTheDocument();
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
it('hit-tests through the declared world spec', () => {
|
|
84
|
+
const rect = { left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 };
|
|
85
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
|
|
86
|
+
rect as unknown as DOMRect,
|
|
87
|
+
);
|
|
88
|
+
const onHitTest = vi.fn();
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<CanvasStack
|
|
91
|
+
layers={makeLayers(1)}
|
|
92
|
+
view={{ zoom: 2, pan: { x: 0, y: 0 } }}
|
|
93
|
+
onViewChange={vi.fn()}
|
|
94
|
+
worldSpec={{ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }}
|
|
95
|
+
onHitTest={onHitTest}
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
const host = container.querySelector('.lk-canvas-stack');
|
|
99
|
+
if (!host) throw new Error('no stack host');
|
|
100
|
+
fireEvent.pointerDown(host, { button: 0, pointerId: 1, clientX: 500, clientY: 200 });
|
|
101
|
+
fireEvent.pointerUp(host, { pointerId: 1, clientX: 500, clientY: 200 });
|
|
102
|
+
|
|
103
|
+
// Origin is (400,300); the cursor is 100px right of it and 100px above it,
|
|
104
|
+
// at zoom 2 with y running up.
|
|
105
|
+
expect(onHitTest).toHaveBeenCalledWith({ x: 50, y: 50 });
|
|
106
|
+
vi.restoreAllMocks();
|
|
107
|
+
});
|
|
82
108
|
});
|
|
@@ -4,17 +4,24 @@ import { CanvasStackContext } from './CanvasStackContext';
|
|
|
4
4
|
import { screenToWorld } from './canvasCoords';
|
|
5
5
|
import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
|
|
6
6
|
import { usePanZoom } from './usePanZoom';
|
|
7
|
+
import { resolveFrame, type ViewportSize, type WorldSpec } from './worldSpec';
|
|
7
8
|
|
|
8
9
|
/** Props for `<CanvasStack>`. */
|
|
9
10
|
export interface CanvasStackProps {
|
|
10
11
|
layers: CanvasLayerDescriptor[];
|
|
11
12
|
view: ViewTransform;
|
|
12
13
|
onViewChange: (v: ViewTransform) => void;
|
|
14
|
+
/** The instrument's coordinate system. Omitted, world (0,0) sits at the
|
|
15
|
+
* element's top-left with y running down. */
|
|
16
|
+
worldSpec?: WorldSpec;
|
|
13
17
|
minZoom?: number;
|
|
14
18
|
maxZoom?: number;
|
|
15
19
|
width?: number | string;
|
|
16
20
|
height?: number | string;
|
|
17
21
|
className?: string;
|
|
22
|
+
/** Fired whenever the stack is measured, so a consumer can place a view that
|
|
23
|
+
* only makes sense in terms of the viewport. */
|
|
24
|
+
onResize?: (size: ViewportSize) => void;
|
|
18
25
|
onHitTest?: (worldPos: Point) => void;
|
|
19
26
|
children?: ReactNode;
|
|
20
27
|
}
|
|
@@ -26,11 +33,13 @@ export function CanvasStack({
|
|
|
26
33
|
layers,
|
|
27
34
|
view,
|
|
28
35
|
onViewChange,
|
|
36
|
+
worldSpec,
|
|
29
37
|
minZoom,
|
|
30
38
|
maxZoom,
|
|
31
39
|
width = '100%',
|
|
32
40
|
height = '100%',
|
|
33
41
|
className,
|
|
42
|
+
onResize,
|
|
34
43
|
onHitTest,
|
|
35
44
|
children,
|
|
36
45
|
}: CanvasStackProps) {
|
|
@@ -63,10 +72,26 @@ export function CanvasStack({
|
|
|
63
72
|
return () => ro.disconnect();
|
|
64
73
|
}, []);
|
|
65
74
|
|
|
66
|
-
const
|
|
67
|
-
|
|
75
|
+
const onResizeRef = useRef(onResize);
|
|
76
|
+
onResizeRef.current = onResize;
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (size.width === 0 && size.height === 0) return;
|
|
79
|
+
onResizeRef.current?.({ width: size.width, height: size.height });
|
|
80
|
+
}, [size.width, size.height]);
|
|
81
|
+
|
|
82
|
+
const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
|
|
68
83
|
|
|
69
|
-
const
|
|
84
|
+
const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame });
|
|
85
|
+
useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
|
|
86
|
+
|
|
87
|
+
const ctxValue = useMemo(
|
|
88
|
+
() => ({
|
|
89
|
+
view,
|
|
90
|
+
frame,
|
|
91
|
+
surface: { element: containerRef, size, canvases: canvasMap, layers },
|
|
92
|
+
}),
|
|
93
|
+
[view, frame, size, layers],
|
|
94
|
+
);
|
|
70
95
|
|
|
71
96
|
const containerStyle: CSSProperties = { width, height };
|
|
72
97
|
const canvasPx = {
|
|
@@ -84,7 +109,7 @@ export function CanvasStack({
|
|
|
84
109
|
if (!wasDragging && onHitTest && containerRef.current) {
|
|
85
110
|
const rect = containerRef.current.getBoundingClientRect();
|
|
86
111
|
const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
87
|
-
onHitTest(screenToWorld(screen, view));
|
|
112
|
+
onHitTest(screenToWorld(screen, view, frame));
|
|
88
113
|
}
|
|
89
114
|
};
|
|
90
115
|
|
|
@@ -1,11 +1,30 @@
|
|
|
1
|
-
import { createContext } from 'react';
|
|
1
|
+
import { createContext, type RefObject } from 'react';
|
|
2
2
|
import type { ViewTransform } from '../instrument/types';
|
|
3
|
+
import type { CanvasLayerDescriptor } from './useLayerScheduler';
|
|
4
|
+
import type { WorldFrame } from './worldSpec';
|
|
3
5
|
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
+
/** The stack's own drawing surface, for an overlay that has to re-draw it at
|
|
7
|
+
* another camera or read back what it presented — a loupe. */
|
|
8
|
+
export interface CanvasStackSurface {
|
|
9
|
+
/** The element the layers are stacked in, and the one pan/zoom listens on. */
|
|
10
|
+
element: RefObject<HTMLElement | null>;
|
|
11
|
+
/** Its measured CSS size, and the ratio the backing stores are scaled by. */
|
|
12
|
+
size: { width: number; height: number; dpr: number };
|
|
13
|
+
/** The presented `<canvas>` per layer id. */
|
|
14
|
+
canvases: RefObject<Map<string, HTMLCanvasElement>>;
|
|
15
|
+
/** The layers as the stack is drawing them, bottom first. */
|
|
16
|
+
layers: readonly CanvasLayerDescriptor[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What a canvas stack publishes to its children: the view, and the resolved
|
|
20
|
+
* coordinate system it is read in, so DOM overlays can place themselves in the
|
|
21
|
+
* same coordinates the layers draw in. */
|
|
6
22
|
export interface CanvasStackContextValue {
|
|
7
23
|
view: ViewTransform;
|
|
24
|
+
frame: WorldFrame;
|
|
25
|
+
/** Absent from a context assembled by hand, which has no stack behind it. */
|
|
26
|
+
surface?: CanvasStackSurface;
|
|
8
27
|
}
|
|
9
28
|
|
|
10
|
-
/** Context carrying the surrounding canvas stack's view. */
|
|
29
|
+
/** Context carrying the surrounding canvas stack's view and world frame. */
|
|
11
30
|
export const CanvasStackContext = createContext<CanvasStackContextValue | null>(null);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { centerOn, zoomAt } from './camera';
|
|
3
|
+
import { screenToWorld, worldToScreen } from './canvasCoords';
|
|
4
|
+
import { DEFAULT_FRAME, resolveFrame } from './worldSpec';
|
|
5
|
+
|
|
6
|
+
const view = { zoom: 2, pan: { x: 30, y: -10 } };
|
|
7
|
+
|
|
8
|
+
describe('zoomAt', () => {
|
|
9
|
+
it('multiplies the zoom by the factor', () => {
|
|
10
|
+
expect(zoomAt(view, 3, { x: 0, y: 0 }).zoom).toBe(6);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps the world point under the anchor fixed', () => {
|
|
14
|
+
const at = { x: 120, y: 80 };
|
|
15
|
+
const before = screenToWorld(at, view);
|
|
16
|
+
const next = zoomAt(view, 3, at);
|
|
17
|
+
expect(screenToWorld(at, next).x).toBeCloseTo(before.x, 10);
|
|
18
|
+
expect(screenToWorld(at, next).y).toBeCloseTo(before.y, 10);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('keeps it fixed on a frame whose origin is not the top-left', () => {
|
|
22
|
+
const frame = resolveFrame(
|
|
23
|
+
{ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' },
|
|
24
|
+
{
|
|
25
|
+
width: 400,
|
|
26
|
+
height: 300,
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
const at = { x: 120, y: 80 };
|
|
30
|
+
const before = screenToWorld(at, view, frame);
|
|
31
|
+
const next = zoomAt(view, 3, at, { frame });
|
|
32
|
+
expect(screenToWorld(at, next, frame).x).toBeCloseTo(before.x, 10);
|
|
33
|
+
expect(screenToWorld(at, next, frame).y).toBeCloseTo(before.y, 10);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('clamps to the bounds, and anchors on the zoom it actually reached', () => {
|
|
37
|
+
const at = { x: 120, y: 80 };
|
|
38
|
+
const next = zoomAt(view, 100, at, { max: 8 });
|
|
39
|
+
expect(next.zoom).toBe(8);
|
|
40
|
+
const before = screenToWorld(at, view);
|
|
41
|
+
expect(screenToWorld(at, next).x).toBeCloseTo(before.x, 10);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('treats a non-finite zoom as 1 rather than propagating NaN', () => {
|
|
45
|
+
const next = zoomAt({ zoom: Number.NaN, pan: { x: 0, y: 0 } }, 2, { x: 10, y: 10 });
|
|
46
|
+
expect(next.zoom).toBe(2);
|
|
47
|
+
expect(Number.isFinite(next.pan.x)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('centerOn', () => {
|
|
52
|
+
it('puts the world point at the middle of the viewport', () => {
|
|
53
|
+
const world = { x: 12, y: -4 };
|
|
54
|
+
const v = centerOn(world, 6, { width: 200, height: 120 });
|
|
55
|
+
const p = worldToScreen(world, v);
|
|
56
|
+
expect(p.x).toBeCloseTo(100, 10);
|
|
57
|
+
expect(p.y).toBeCloseTo(60, 10);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('respects the frame it is given', () => {
|
|
61
|
+
const frame = resolveFrame(
|
|
62
|
+
{ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' },
|
|
63
|
+
{
|
|
64
|
+
width: 200,
|
|
65
|
+
height: 120,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
const world = { x: 12, y: -4 };
|
|
69
|
+
const v = centerOn(world, 6, { width: 200, height: 120 }, frame);
|
|
70
|
+
const p = worldToScreen(world, v, frame);
|
|
71
|
+
expect(p.x).toBeCloseTo(100, 10);
|
|
72
|
+
expect(p.y).toBeCloseTo(60, 10);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('carries the zoom it was given', () => {
|
|
76
|
+
expect(centerOn({ x: 0, y: 0 }, 6, { width: 10, height: 10 }, DEFAULT_FRAME).zoom).toBe(6);
|
|
77
|
+
});
|
|
78
|
+
});
|