@weasel-js/labkit 1.6.0 → 1.7.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-DQgcH1ny.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
- package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
- package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-DiuEArKq.d.ts} +4 -4
- package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
- package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
- package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Bensr_MT.d.ts} +15 -6
- package/dist/_dts/Stage-CCWfQQWM.d.ts +202 -0
- package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
- package/dist/_dts/{index-B0--8PEM.d.ts → index-CsYqUHD3.d.ts} +12 -13
- package/dist/_dts/{types-B7y8hX1D.d.ts → types-CRPSGFAp.d.ts} +33 -5
- package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-CSm9jgol.d.ts} +700 -650
- package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CTZPgeZ7.d.ts} +2 -3
- package/dist/canvas/index.d.ts +40 -8
- package/dist/canvas/index.js +5 -3
- package/dist/chrome/index.d.ts +5 -6
- package/dist/chrome/index.js +9 -7
- package/dist/chunk-24W5VEJW.js +18 -0
- package/dist/chunk-24W5VEJW.js.map +1 -0
- package/dist/chunk-275JRRVD.js +728 -0
- package/dist/chunk-275JRRVD.js.map +1 -0
- package/dist/chunk-3RY6WL5T.js +38 -0
- package/dist/chunk-3RY6WL5T.js.map +1 -0
- package/dist/chunk-4LJSC7MB.js +143 -0
- package/dist/chunk-4LJSC7MB.js.map +1 -0
- package/dist/{chunk-3FI7Y4R4.js → chunk-7BO7HNGF.js} +5 -5
- package/dist/{chunk-3FI7Y4R4.js.map → chunk-7BO7HNGF.js.map} +1 -1
- package/dist/chunk-AWQG7FCC.js +19 -0
- package/dist/chunk-AWQG7FCC.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-ZORVNOKX.js → chunk-FMK7UQ6V.js} +24 -145
- package/dist/chunk-FMK7UQ6V.js.map +1 -0
- package/dist/{chunk-BVL7Y574.js → chunk-FZ7YFQN7.js} +26 -10
- package/dist/chunk-FZ7YFQN7.js.map +1 -0
- package/dist/{chunk-USO6TERP.js → chunk-GOZLNM4L.js} +11 -9
- package/dist/chunk-GOZLNM4L.js.map +1 -0
- package/dist/{chunk-H5VIV7MQ.js → chunk-I5DUQ5HM.js} +88 -82
- package/dist/chunk-I5DUQ5HM.js.map +1 -0
- package/dist/chunk-J7OWWTCW.js +143 -0
- package/dist/chunk-J7OWWTCW.js.map +1 -0
- package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
- package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
- package/dist/{chunk-VQ47KWD5.js → chunk-KMJ7RWD3.js} +18 -7
- package/dist/chunk-KMJ7RWD3.js.map +1 -0
- package/dist/{chunk-ERFMC6WU.js → chunk-M7HP3FTK.js} +20 -11
- package/dist/chunk-M7HP3FTK.js.map +1 -0
- package/dist/chunk-MIM5R4ZT.js +14972 -0
- package/dist/chunk-MIM5R4ZT.js.map +1 -0
- package/dist/{chunk-HFRE4QQV.js → chunk-T5IBSPAM.js} +96 -32
- package/dist/chunk-T5IBSPAM.js.map +1 -0
- package/dist/chunk-UNAZSLWE.js +294 -0
- package/dist/chunk-UNAZSLWE.js.map +1 -0
- package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
- package/dist/chunk-WPLAANH4.js.map +1 -0
- package/dist/{chunk-AWH3SRPW.js → chunk-Y23G2VZL.js} +5 -37
- package/dist/chunk-Y23G2VZL.js.map +1 -0
- package/dist/{chunk-MOLIUNVH.js → chunk-YT75VYRD.js} +4 -4
- package/dist/{chunk-MOLIUNVH.js.map → chunk-YT75VYRD.js.map} +1 -1
- package/dist/chunk-ZIFPHIWZ.js +302 -0
- package/dist/chunk-ZIFPHIWZ.js.map +1 -0
- package/dist/config/index.d.ts +48 -40
- package/dist/config/index.js +4 -5
- package/dist/controls/index.d.ts +91 -7
- package/dist/controls/index.js +5 -4
- package/dist/dragdrop/index.d.ts +9 -7
- package/dist/dragdrop/index.js +3 -2
- package/dist/index.d.ts +294 -82
- package/dist/index.js +425 -402
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -5
- package/dist/layers/index.js +2 -1
- package/dist/loupe/index.d.ts +20 -17
- package/dist/loupe/index.js +5 -3
- package/dist/overview/index.d.ts +56 -0
- package/dist/overview/index.js +289 -0
- package/dist/overview/index.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +239 -69
- package/dist/passthrough/weasel-ui.js +3 -2
- package/dist/primitives/index.d.ts +5 -204
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +8 -8
- package/dist/state/index.js +4 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +152 -82
- package/dist/undo/index.d.ts +4 -4
- package/package.json +17 -12
- package/src/annotations/AnnotationOverlay.tsx +2 -2
- package/src/annotations/Annotations.less +0 -10
- package/src/annotations/MarkList.tsx +4 -8
- package/src/annotations/index.ts +1 -0
- package/src/annotations/paint.test.ts +3 -14
- package/src/annotations/paint.ts +3 -19
- package/src/annotations/store.test.ts +26 -0
- package/src/annotations/store.ts +25 -6
- package/src/annotations/svgNodes.test.ts +6 -9
- package/src/annotations/svgNodes.ts +3 -3
- package/src/annotations/toolMap.test.ts +37 -2
- package/src/annotations/toolMap.ts +13 -0
- package/src/annotations/types.ts +27 -0
- package/src/canvas/AGENTS.md +9 -10
- package/src/canvas/CameraInput.test.tsx +136 -0
- package/src/canvas/CameraInput.tsx +322 -0
- package/src/canvas/CanvasStack.tsx +47 -58
- package/src/canvas/LinkedCursor.less +16 -0
- package/src/canvas/LinkedCursor.tsx +54 -0
- package/src/canvas/Stage.test.tsx +33 -4
- package/src/canvas/Stage.tsx +47 -37
- package/src/canvas/cameraRegistry.test.ts +55 -0
- package/src/canvas/cameraRegistry.ts +86 -0
- package/src/canvas/cameraView.test.ts +60 -0
- package/src/canvas/cameraView.ts +61 -0
- package/src/canvas/cameraZoom.test.ts +46 -0
- package/src/canvas/cameraZoom.ts +24 -0
- package/src/canvas/index.ts +23 -2
- package/src/canvas/sharedScope.test.tsx +94 -0
- package/src/chrome/ChromeRegions.stories.tsx +1 -0
- package/src/chrome/regions/PaletteRegion.less +0 -7
- package/src/chrome/regions/SidebarRegion.less +12 -2
- package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
- package/src/config/builder.test.ts +2 -1
- package/src/config/builder.ts +15 -8
- package/src/config/entry.test.ts +3 -1
- package/src/config/fromConfigField.test.ts +14 -0
- package/src/config/fromConfigField.ts +19 -4
- package/src/config/index.ts +4 -1
- package/src/config/path.ts +2 -5
- package/src/config/resolve.ts +15 -1
- package/src/config/sectionTree.test.ts +12 -6
- package/src/config/sectionTree.ts +15 -5
- package/src/config/types.ts +34 -9
- package/src/controls/ControlPanel.less +1 -1
- package/src/controls/ControlPanel.stories.tsx +3 -2
- package/src/controls/ControlPanel.test.tsx +76 -13
- package/src/controls/ControlPanel.tsx +80 -11
- package/src/controls/inDialog.test.tsx +18 -0
- package/src/controls/inDialog.tsx +2 -10
- package/src/index.test.ts +1 -1
- package/src/index.ts +23 -5
- package/src/instrument/SineWave.smoke.test.tsx +2 -0
- package/src/instrument/types.ts +10 -1
- package/src/lab/Lab.nebula.test.tsx +11 -2
- package/src/lab/Lab.test.tsx +3 -1
- package/src/lab/Lab.tsx +87 -76
- 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 +8 -17
- package/src/lab/LabSwitcher.less +6 -6
- package/src/lab/LabZoom.test.tsx +223 -0
- package/src/lab/LabZoom.tsx +170 -0
- package/src/lab/index.ts +1 -0
- package/src/loupe/AGENTS.md +7 -6
- package/src/loupe/LoupeGestures.tsx +15 -10
- package/src/loupe/TrialLoupe.tsx +5 -5
- package/src/loupe/index.ts +1 -0
- package/src/loupe/loupeActions.ts +6 -2
- package/src/overview/AGENTS.md +38 -0
- package/src/overview/OverviewMarks.test.tsx +100 -0
- package/src/overview/OverviewMarks.tsx +93 -0
- package/src/overview/TrialOverview.less +53 -0
- package/src/overview/TrialOverview.stories.tsx +50 -0
- package/src/overview/TrialOverview.test.tsx +116 -0
- package/src/overview/TrialOverview.tsx +376 -0
- package/src/overview/index.ts +2 -0
- package/src/passthrough/weasel-ui.test.ts +2 -0
- package/src/passthrough/weasel-ui.ts +23 -4
- package/src/primitives/FloatingPanel.test.tsx +11 -0
- package/src/primitives/FloatingPanel.tsx +2 -5
- package/src/primitives/JobProgress.less +1 -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/index.ts +2 -0
- package/src/specimen/Specimen.stories.tsx +1 -0
- package/src/specimen/Specimen.tsx +21 -4
- package/src/state/Persistence.browser.test.tsx +47 -0
- package/src/state/labRecords.test.ts +4 -1
- package/src/state/store.test.ts +9 -5
- package/src/state/usePersistedState.test.tsx +1 -1
- package/src/state/useTrialState.test.tsx +1 -1
- package/src/styles.less +3 -0
- package/src/theme/Interstellar.stories.tsx +4 -6
- 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/tools/labTool.ts +6 -2
- package/src/trial/Trial.annotations.test.tsx +16 -0
- package/src/trial/Trial.less +0 -13
- package/src/trial/Trial.loupe.test.tsx +73 -1
- package/src/trial/Trial.stories.tsx +2 -0
- package/src/trial/Trial.tsx +23 -1
- package/src/trial/TrialChrome.tsx +21 -3
- package/src/trial/trialOps.test.ts +4 -4
- package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
- package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
- package/dist/_dts/index-DINmCuLA.d.ts +0 -453
- package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
- package/dist/_dts/usePanZoom-Bq73pwFe.d.ts +0 -117
- package/dist/chunk-2AYGEN57.js +0 -32
- package/dist/chunk-2AYGEN57.js.map +0 -1
- package/dist/chunk-AWH3SRPW.js.map +0 -1
- package/dist/chunk-BVL7Y574.js.map +0 -1
- package/dist/chunk-ERFMC6WU.js.map +0 -1
- package/dist/chunk-H5VIV7MQ.js.map +0 -1
- package/dist/chunk-HFRE4QQV.js.map +0 -1
- package/dist/chunk-RS3HRQWU.js.map +0 -1
- package/dist/chunk-SBXNQW4G.js +0 -3
- package/dist/chunk-TT5C5S6R.js +0 -368
- package/dist/chunk-TT5C5S6R.js.map +0 -1
- package/dist/chunk-USO6TERP.js.map +0 -1
- package/dist/chunk-VQ47KWD5.js.map +0 -1
- package/dist/chunk-WUJXJC5O.js +0 -14568
- package/dist/chunk-WUJXJC5O.js.map +0 -1
- package/dist/chunk-ZORVNOKX.js.map +0 -1
- package/src/canvas/usePanZoom.test.ts +0 -244
- package/src/canvas/usePanZoom.ts +0 -147
- package/src/state/Persistence.stories.tsx +0 -75
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overview draws every target's marks where that target sits on the stage,
|
|
3
|
+
* and redraws when one is added. jsdom lays nothing out, so the target's rect
|
|
4
|
+
* and the stage's are mocked; the stage's camera is the identity there.
|
|
5
|
+
*/
|
|
6
|
+
import { act, render } from '@testing-library/react';
|
|
7
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { useAnnotations } from '../annotations/AnnotationsContext';
|
|
9
|
+
import type { AnnotationsApi } from '../annotations/types';
|
|
10
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
11
|
+
import { Lab } from '../lab/Lab';
|
|
12
|
+
import { TrialOverview } from './TrialOverview';
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
16
|
+
() => null,
|
|
17
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const rect = (left: number, top: number, width: number, height: number) =>
|
|
21
|
+
({
|
|
22
|
+
left,
|
|
23
|
+
top,
|
|
24
|
+
width,
|
|
25
|
+
height,
|
|
26
|
+
right: left + width,
|
|
27
|
+
bottom: top + height,
|
|
28
|
+
x: left,
|
|
29
|
+
y: top,
|
|
30
|
+
}) as DOMRect;
|
|
31
|
+
|
|
32
|
+
let restore: (() => void) | null = null;
|
|
33
|
+
afterEach(() => restore?.());
|
|
34
|
+
|
|
35
|
+
/** Stage 400×300, the pane at stage (50, 20) and 200×100. */
|
|
36
|
+
function mount() {
|
|
37
|
+
const pane = { current: null as HTMLDivElement | null };
|
|
38
|
+
let api: AnnotationsApi | null = null;
|
|
39
|
+
function Body() {
|
|
40
|
+
api = useAnnotations();
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={(el) => {
|
|
44
|
+
pane.current = el;
|
|
45
|
+
}}
|
|
46
|
+
data-testid="pane"
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const marked = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
51
|
+
name: 'Marked',
|
|
52
|
+
defaultConfig: () => ({}),
|
|
53
|
+
initialState: () => ({}),
|
|
54
|
+
render: () => <Body />,
|
|
55
|
+
stage: {
|
|
56
|
+
size: { width: 400, height: 300 },
|
|
57
|
+
overlay: () => <TrialOverview width={200} height={150} render={() => <div />} />,
|
|
58
|
+
},
|
|
59
|
+
annotations: {
|
|
60
|
+
targets: () => [{ id: 'pane', ref: pane, content: { w: 200, h: 100 } }],
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const original = HTMLElement.prototype.getBoundingClientRect;
|
|
64
|
+
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
|
|
65
|
+
if (this === pane.current) return rect(50, 20, 200, 100);
|
|
66
|
+
return rect(0, 0, 0, 0);
|
|
67
|
+
};
|
|
68
|
+
restore = () => {
|
|
69
|
+
HTMLElement.prototype.getBoundingClientRect = original;
|
|
70
|
+
};
|
|
71
|
+
const r = render(<Lab instruments={[marked]} defaultInstrument="Marked" />);
|
|
72
|
+
return { ...r, api: () => api as AnnotationsApi };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const px = (el: HTMLElement, name: string) => Number.parseFloat(el.style.getPropertyValue(name));
|
|
76
|
+
|
|
77
|
+
describe('marks on the overview', () => {
|
|
78
|
+
it('draws nothing for a target with no marks', () => {
|
|
79
|
+
const h = mount();
|
|
80
|
+
expect(h.container.querySelector('.lk-overview__marks')).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("places a target's marks at the target's rect, through the fit", () => {
|
|
84
|
+
const h = mount();
|
|
85
|
+
act(() => {
|
|
86
|
+
h.api().add({ target: 'pane', kind: 'rect', frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } });
|
|
87
|
+
});
|
|
88
|
+
const marks = h.container.querySelector<HTMLElement>('.lk-overview__marks[data-target="pane"]');
|
|
89
|
+
expect(marks).not.toBeNull();
|
|
90
|
+
if (!marks) return;
|
|
91
|
+
// Content 400×300 into 200×150, 6px in: scale 0.46, camera at
|
|
92
|
+
// (200 - 100 / 0.46, 150 - 75 / 0.46).
|
|
93
|
+
const s = 0.46;
|
|
94
|
+
expect(px(marks, '--lk-overview-mx')).toBeCloseTo((50 - (200 - 100 / s)) * s);
|
|
95
|
+
expect(px(marks, '--lk-overview-my')).toBeCloseTo((20 - (150 - 75 / s)) * s);
|
|
96
|
+
expect(px(marks, '--lk-overview-mw')).toBeCloseTo(200 * s);
|
|
97
|
+
expect(px(marks, '--lk-overview-mh')).toBeCloseTo(100 * s);
|
|
98
|
+
expect(marks.querySelector('svg path')).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every annotation target's marks, drawn read-only on the overview through the
|
|
3
|
+
* export's own vector path: `markSvgNodes`, then `serializeSvg`.
|
|
4
|
+
*/
|
|
5
|
+
import type { View } from '@weasel-js/core';
|
|
6
|
+
import { serializeSvg } from '@weasel-js/svg';
|
|
7
|
+
import { type CSSProperties, type RefObject, useEffect, useReducer } from 'react';
|
|
8
|
+
import { useAnnotationsOptional } from '../annotations/AnnotationsContext';
|
|
9
|
+
import { markSvgNodes } from '../annotations/svgNodes';
|
|
10
|
+
import type { AnnotationsApi } from '../annotations/types';
|
|
11
|
+
import type { CameraContextValue } from '../canvas/CameraInput';
|
|
12
|
+
|
|
13
|
+
/** A target's box, in the camera's frame-local units. */
|
|
14
|
+
interface LocalRect {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
w: number;
|
|
18
|
+
h: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Where `el` sits in the camera's frame-local units: its screen rect,
|
|
22
|
+
* measured against the camera's element, through the camera. */
|
|
23
|
+
function localRectOf(el: HTMLElement, camera: CameraContextValue): LocalRect | null {
|
|
24
|
+
const host = camera.element();
|
|
25
|
+
if (!host) return null;
|
|
26
|
+
const r = el.getBoundingClientRect();
|
|
27
|
+
const h = host.getBoundingClientRect();
|
|
28
|
+
const v = camera.view.get();
|
|
29
|
+
return {
|
|
30
|
+
x: (r.left - h.left) / v.scale.x + v.x,
|
|
31
|
+
y: (r.top - h.top) / v.scale.y + v.y,
|
|
32
|
+
w: r.width / v.scale.x,
|
|
33
|
+
h: r.height / v.scale.y,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The element a target's marks sit on. The capability's targets carry their
|
|
38
|
+
* `ref`; the store's type names only the half it reads. */
|
|
39
|
+
function elementOf(target: object): HTMLElement | null {
|
|
40
|
+
return (target as { ref?: RefObject<HTMLElement | null> }).ref?.current ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function markupOf(api: AnnotationsApi, target: string, content: { w: number; h: number }): string {
|
|
44
|
+
const painted = api.paintedMarks(target);
|
|
45
|
+
if (painted.length === 0) return '';
|
|
46
|
+
const nodes = painted.flatMap(({ mark, style }) => markSvgNodes(mark, content, style));
|
|
47
|
+
return serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: content.w, height: content.h } });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Props for `<OverviewMarks>`. */
|
|
51
|
+
export interface OverviewMarksProps {
|
|
52
|
+
fit: View;
|
|
53
|
+
camera: CameraContextValue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The marks of every target in the annotations store in scope, placed where
|
|
58
|
+
* each target's element sits on the stage. Draws nothing outside an annotating
|
|
59
|
+
* trial, and takes no input.
|
|
60
|
+
*/
|
|
61
|
+
export function OverviewMarks({ fit, camera }: OverviewMarksProps) {
|
|
62
|
+
const api = useAnnotationsOptional();
|
|
63
|
+
const [, redraw] = useReducer((n: number) => n + 1, 0);
|
|
64
|
+
useEffect(() => api?.subscribe(redraw), [api]);
|
|
65
|
+
if (!api) return null;
|
|
66
|
+
const s = fit.scale.x;
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
{api.targets().map((t) => {
|
|
70
|
+
const el = elementOf(t);
|
|
71
|
+
const box = el ? localRectOf(el, camera) : null;
|
|
72
|
+
const markup = box ? markupOf(api, t.id, t.content) : '';
|
|
73
|
+
if (!box || !markup) return null;
|
|
74
|
+
const place = {
|
|
75
|
+
['--lk-overview-mx' as string]: `${(box.x - fit.x) * s}px`,
|
|
76
|
+
['--lk-overview-my' as string]: `${(box.y - fit.y) * s}px`,
|
|
77
|
+
['--lk-overview-mw' as string]: `${box.w * s}px`,
|
|
78
|
+
['--lk-overview-mh' as string]: `${box.h * s}px`,
|
|
79
|
+
} as CSSProperties;
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
key={t.id}
|
|
83
|
+
className="lk-overview__marks"
|
|
84
|
+
style={place}
|
|
85
|
+
data-target={t.id}
|
|
86
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: the export's SVG serializer is the one vector path marks have; re-deriving React nodes would be a second.
|
|
87
|
+
dangerouslySetInnerHTML={{ __html: markup }}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.lk-overview__title {
|
|
2
|
+
padding: 2px 6px 4px;
|
|
3
|
+
font-size: var(--wzl-font-size-sm);
|
|
4
|
+
color: var(--wzl-fg-muted);
|
|
5
|
+
text-transform: uppercase;
|
|
6
|
+
letter-spacing: 0.05em;
|
|
7
|
+
cursor: grab;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lk-overview__box {
|
|
11
|
+
position: relative;
|
|
12
|
+
width: var(--lk-overview-w);
|
|
13
|
+
height: var(--lk-overview-h);
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
background: var(--wzl-surface-sunken);
|
|
16
|
+
touch-action: none;
|
|
17
|
+
cursor: crosshair;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.lk-overview__content {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: 0;
|
|
23
|
+
left: 0;
|
|
24
|
+
width: var(--lk-overview-cw);
|
|
25
|
+
height: var(--lk-overview-ch);
|
|
26
|
+
transform-origin: 0 0;
|
|
27
|
+
transform: translate(var(--lk-overview-x), var(--lk-overview-y)) scale(var(--lk-overview-zoom));
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.lk-overview__canvas {
|
|
32
|
+
position: absolute;
|
|
33
|
+
inset: 0;
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.lk-overview__marks {
|
|
40
|
+
position: absolute;
|
|
41
|
+
left: var(--lk-overview-mx);
|
|
42
|
+
top: var(--lk-overview-my);
|
|
43
|
+
width: var(--lk-overview-mw);
|
|
44
|
+
height: var(--lk-overview-mh);
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.lk-overview__marks > svg {
|
|
49
|
+
display: block;
|
|
50
|
+
width: 100%;
|
|
51
|
+
height: 100%;
|
|
52
|
+
overflow: visible;
|
|
53
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@weasel-js/forge';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Stage } from '../canvas/Stage';
|
|
4
|
+
import type { ViewTransform } from '../instrument/types';
|
|
5
|
+
import { TrialOverview } from './TrialOverview';
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof TrialOverview> = {
|
|
8
|
+
title: 'labkit/Overview/TrialOverview',
|
|
9
|
+
component: TrialOverview,
|
|
10
|
+
parameters: { layout: 'fullscreen' },
|
|
11
|
+
};
|
|
12
|
+
export default meta;
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof TrialOverview>;
|
|
15
|
+
|
|
16
|
+
const SIZE = { width: 640, height: 420 };
|
|
17
|
+
const DOTS = [40, 120, 200, 280, 360, 440, 520, 600];
|
|
18
|
+
|
|
19
|
+
/** A picture bigger than the stage at 2×, so there is somewhere to go. */
|
|
20
|
+
function Picture() {
|
|
21
|
+
return (
|
|
22
|
+
<svg viewBox="0 0 640 420" width={SIZE.width} height={SIZE.height} role="img" aria-label="Grid">
|
|
23
|
+
<title>Grid</title>
|
|
24
|
+
<rect width="640" height="420" fill="#f4f1ea" />
|
|
25
|
+
{DOTS.map((x, i) => (
|
|
26
|
+
<circle key={x} cx={x} cy={40 + i * 48} r="24" fill="#8b98a8" />
|
|
27
|
+
))}
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Harness() {
|
|
33
|
+
const [view, setView] = useState<ViewTransform>({ zoom: 2, pan: { x: -200, y: -120 } });
|
|
34
|
+
return (
|
|
35
|
+
<div style={{ width: '100vw', height: '100vh' }}>
|
|
36
|
+
<Stage
|
|
37
|
+
size={SIZE}
|
|
38
|
+
view={view}
|
|
39
|
+
onViewChange={setView}
|
|
40
|
+
overlay={<TrialOverview width={200} height={140} render={() => <Picture />} />}
|
|
41
|
+
>
|
|
42
|
+
<Picture />
|
|
43
|
+
</Stage>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Default: Story = {
|
|
49
|
+
render: () => <Harness />,
|
|
50
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A trial's overview: a press in it moves the stage's camera there, and the
|
|
3
|
+
* pointer over it is published as the overview's, which the stage then draws.
|
|
4
|
+
*
|
|
5
|
+
* jsdom lays nothing out, so every rect measures zero: a client point is
|
|
6
|
+
* already box-relative, and the stage's viewport has no size to center in —
|
|
7
|
+
* centering puts the pressed world point at the stage's origin instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { act, render } from '@testing-library/react';
|
|
11
|
+
import type { PointerContextValue } from '@weasel-js/core';
|
|
12
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
14
|
+
import { Lab } from '../lab/Lab';
|
|
15
|
+
import { TrialOverview } from './TrialOverview';
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
19
|
+
() => null,
|
|
20
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
21
|
+
const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
|
|
22
|
+
proto.setPointerCapture = vi.fn();
|
|
23
|
+
proto.releasePointerCapture = vi.fn();
|
|
24
|
+
proto.hasPointerCapture = vi.fn(() => true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
|
|
28
|
+
el.dispatchEvent(
|
|
29
|
+
new PointerEvent(type, {
|
|
30
|
+
bubbles: true,
|
|
31
|
+
cancelable: true,
|
|
32
|
+
pointerId: 1,
|
|
33
|
+
clientX: x,
|
|
34
|
+
clientY: y,
|
|
35
|
+
button: 0,
|
|
36
|
+
buttons,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Content 400×300 in a 200×150 box, 6px in: scale 0.46, so box (100, 75)
|
|
42
|
+
* is content (200, 150). */
|
|
43
|
+
function mount() {
|
|
44
|
+
let store: PointerContextValue | null = null;
|
|
45
|
+
const pictured = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
46
|
+
name: 'Pictured',
|
|
47
|
+
defaultConfig: () => ({}),
|
|
48
|
+
initialState: () => ({}),
|
|
49
|
+
render: (ctx) => {
|
|
50
|
+
store = ctx.trial.pointer;
|
|
51
|
+
return <div data-testid="picture" />;
|
|
52
|
+
},
|
|
53
|
+
stage: {
|
|
54
|
+
size: { width: 400, height: 300 },
|
|
55
|
+
overlay: () => (
|
|
56
|
+
<TrialOverview width={200} height={150} render={() => <div data-testid="thumb" />} />
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const r = render(<Lab instruments={[pictured]} defaultInstrument="Pictured" />);
|
|
61
|
+
const box = r.container.querySelector('.lk-overview__box');
|
|
62
|
+
const content = r.container.querySelector('.lk-stage__content') as HTMLElement | null;
|
|
63
|
+
if (!box || !content) throw new Error('no overview or stage');
|
|
64
|
+
return { ...r, box, content, store: () => store as PointerContextValue | null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const px = (el: HTMLElement, name: string) => Number.parseFloat(el.style.getPropertyValue(name));
|
|
68
|
+
|
|
69
|
+
describe('<TrialOverview>', () => {
|
|
70
|
+
it('draws the instrument-supplied content, not the instrument itself', () => {
|
|
71
|
+
const h = mount();
|
|
72
|
+
expect(h.box.querySelector('[data-testid="thumb"]')).not.toBeNull();
|
|
73
|
+
expect(h.box.querySelector('[data-testid="picture"]')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('moves the stage camera to the pressed point', () => {
|
|
77
|
+
const h = mount();
|
|
78
|
+
act(() => {
|
|
79
|
+
pointer(h.box, 'pointerdown', 100, 75);
|
|
80
|
+
pointer(h.box, 'pointerup', 100, 75, 0);
|
|
81
|
+
});
|
|
82
|
+
expect(px(h.content, '--lk-stage-x')).toBeCloseTo(-200);
|
|
83
|
+
expect(px(h.content, '--lk-stage-y')).toBeCloseTo(-150);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('follows a drag with the camera, and never pans the stage by it', () => {
|
|
87
|
+
const h = mount();
|
|
88
|
+
act(() => {
|
|
89
|
+
pointer(h.box, 'pointerdown', 100, 75);
|
|
90
|
+
pointer(h.box, 'pointermove', 110, 75);
|
|
91
|
+
pointer(h.box, 'pointermove', 123, 75);
|
|
92
|
+
pointer(h.box, 'pointerup', 123, 75, 0);
|
|
93
|
+
});
|
|
94
|
+
// Box x 123 is content x 250 — a stage pan by the 23px drag would land
|
|
95
|
+
// elsewhere.
|
|
96
|
+
expect(px(h.content, '--lk-stage-x')).toBeCloseTo(-250);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("publishes the pointer over it as the overview's, and the stage draws it", () => {
|
|
100
|
+
const h = mount();
|
|
101
|
+
act(() => pointer(h.box, 'pointermove', 100, 75, 0));
|
|
102
|
+
const p = h.store()?.get();
|
|
103
|
+
expect(p?.viewId).toBe('overview');
|
|
104
|
+
expect(p?.worldX).toBeCloseTo(200);
|
|
105
|
+
expect(p?.worldY).toBeCloseTo(150);
|
|
106
|
+
expect(h.container.querySelector('.lk-stage .lk-linked-cursor')).not.toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("publishes the stage's pointer as the stage's, so the stage draws none", () => {
|
|
110
|
+
const h = mount();
|
|
111
|
+
const host = h.container.querySelector('.lk-stage') as HTMLElement;
|
|
112
|
+
act(() => pointer(host, 'pointermove', 10, 10, 0));
|
|
113
|
+
expect(h.store()?.get()?.viewId).toBe('stage');
|
|
114
|
+
expect(h.container.querySelector('.lk-stage .lk-linked-cursor')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|