@weasel-js/labkit 1.0.4 → 1.2.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/README.md +33 -6
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
- package/dist/canvas/index.d.ts +2 -1
- package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
- package/dist/chunk-73KA7WBO.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
- package/dist/chunk-BOHF3PQO.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/chunk-THBG7FQZ.js +167 -0
- package/dist/chunk-THBG7FQZ.js.map +1 -0
- package/dist/chunk-VUU5UXHE.js +491 -0
- package/dist/chunk-VUU5UXHE.js.map +1 -0
- package/dist/dragdrop/index.d.ts +2 -1
- package/dist/index.d.ts +211 -151
- package/dist/index.js +317 -169
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +3 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +29 -6
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +12 -12
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +31 -13
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +3 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/canvas/AGENTS.md +14 -2
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +70 -0
- package/src/index.ts +24 -7
- package/src/instrument/SineWave.smoke.test.tsx +2 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +30 -6
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +6 -7
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +50 -50
- package/src/lab/LabContext.ts +12 -12
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
- package/src/lab/index.ts +2 -2
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +21 -29
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -41
- package/src/state/store.ts +165 -102
- package/src/state/types.ts +32 -13
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +2 -2
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +157 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/trial/DefaultStatusBar.tsx +19 -0
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/trial/Trial.job.test.tsx +75 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +18 -17
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
- package/dist/chunk-BAPZPDDA.js.map +0 -1
- package/dist/chunk-DWV7SFKR.js.map +0 -1
- package/dist/chunk-KSTEW2AF.js +0 -367
- package/dist/chunk-KSTEW2AF.js.map +0 -1
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/DefaultStatusBar.tsx +0 -18
- package/src/workspace/workspaceOps.ts +0 -102
|
@@ -4,8 +4,9 @@ import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
|
4
4
|
import { noneAdapter } from '../state/adapters';
|
|
5
5
|
import { LabStoreContext } from '../state/context';
|
|
6
6
|
import { createLabStore } from '../state/store';
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
7
|
+
import type { TrialRecord } from '../state/types';
|
|
8
|
+
import { DEFAULT_VIEW } from '../state/view';
|
|
9
|
+
import { TrialChrome } from './TrialChrome';
|
|
9
10
|
|
|
10
11
|
const noop = () => {};
|
|
11
12
|
|
|
@@ -16,7 +17,7 @@ const stub: Instrument = {
|
|
|
16
17
|
render: () => <div className="lk-stub-display">stub experiment area</div>,
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
const record:
|
|
20
|
+
const record: TrialRecord = {
|
|
20
21
|
id: 'ws-demo',
|
|
21
22
|
instrumentName: 'Stub',
|
|
22
23
|
config: {},
|
|
@@ -29,12 +30,12 @@ function Harness() {
|
|
|
29
30
|
const store = createLabStore({ storageKey: 'sb', storage: noneAdapter });
|
|
30
31
|
const lab: LabContextValue = {
|
|
31
32
|
instruments: [stub],
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
trials: [record],
|
|
34
|
+
addTrial: noop,
|
|
35
|
+
cloneTrial: noop,
|
|
36
|
+
closeTrial: noop,
|
|
37
|
+
resetTrial: noop,
|
|
38
|
+
reorderTrials: noop,
|
|
38
39
|
savedSnapshots: [],
|
|
39
40
|
saveSnapshot: noop,
|
|
40
41
|
loadSnapshot: noop,
|
|
@@ -46,21 +47,22 @@ function Harness() {
|
|
|
46
47
|
<LabStoreContext.Provider value={{ store }}>
|
|
47
48
|
<LabContext.Provider value={lab}>
|
|
48
49
|
<div style={{ height: '500px' }}>
|
|
49
|
-
<
|
|
50
|
-
workspaceId="ws-demo"
|
|
51
|
-
record={record}
|
|
52
|
-
instrument={stub}
|
|
53
|
-
isLastWorkspace={true}
|
|
54
|
-
>
|
|
50
|
+
<TrialChrome trialId="ws-demo" record={record} instrument={stub} isLastTrial={true}>
|
|
55
51
|
{stub.render({
|
|
56
52
|
state: {},
|
|
57
53
|
config: {},
|
|
58
54
|
setState: () => {},
|
|
59
55
|
setConfig: () => {},
|
|
60
|
-
|
|
56
|
+
trial: {
|
|
57
|
+
id: 'ws-demo',
|
|
58
|
+
view: DEFAULT_VIEW,
|
|
59
|
+
setView: () => {},
|
|
60
|
+
zoom: 1,
|
|
61
|
+
setZoom: () => {},
|
|
62
|
+
},
|
|
61
63
|
emit: () => {},
|
|
62
64
|
})}
|
|
63
|
-
</
|
|
65
|
+
</TrialChrome>
|
|
64
66
|
</div>
|
|
65
67
|
</LabContext.Provider>
|
|
66
68
|
</LabStoreContext.Provider>
|
|
@@ -68,7 +70,7 @@ function Harness() {
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
const meta: Meta<typeof Harness> = {
|
|
71
|
-
title: 'labkit/
|
|
73
|
+
title: 'labkit/Trial/Trial',
|
|
72
74
|
component: Harness,
|
|
73
75
|
parameters: { layout: 'fullscreen' },
|
|
74
76
|
};
|
|
@@ -6,14 +6,14 @@ import { Lab } from '../lab/Lab';
|
|
|
6
6
|
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
7
7
|
import { LabStoreContext } from '../state/context';
|
|
8
8
|
import { createLabStore } from '../state/store';
|
|
9
|
-
import type {
|
|
9
|
+
import type { TrialRecord } from '../state/types';
|
|
10
10
|
import { DefaultToolbar } from './DefaultToolbar';
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
11
|
+
import type { TrialToolbarContext } from './slotTypes';
|
|
12
|
+
import { TrialChrome } from './TrialChrome';
|
|
13
13
|
|
|
14
|
-
function makeCtx(overrides: Partial<
|
|
14
|
+
function makeCtx(overrides: Partial<TrialToolbarContext> = {}): TrialToolbarContext {
|
|
15
15
|
return {
|
|
16
|
-
|
|
16
|
+
trialId: 'ws-1',
|
|
17
17
|
instrumentName: 'Stub',
|
|
18
18
|
hasUndo: false,
|
|
19
19
|
canUndo: false,
|
|
@@ -32,7 +32,7 @@ function makeCtx(overrides: Partial<WorkspaceToolbarContext> = {}): WorkspaceToo
|
|
|
32
32
|
clone: vi.fn(),
|
|
33
33
|
reset: vi.fn(),
|
|
34
34
|
close: vi.fn(),
|
|
35
|
-
|
|
35
|
+
isLastTrial: false,
|
|
36
36
|
...overrides,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
@@ -43,8 +43,8 @@ describe('<DefaultToolbar>', () => {
|
|
|
43
43
|
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
it('disables close when
|
|
47
|
-
render(<DefaultToolbar ctx={makeCtx({
|
|
46
|
+
it('disables close when isLastTrial is true', () => {
|
|
47
|
+
render(<DefaultToolbar ctx={makeCtx({ isLastTrial: true })} />);
|
|
48
48
|
expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
|
|
49
49
|
});
|
|
50
50
|
|
|
@@ -79,7 +79,7 @@ describe('<DefaultToolbar>', () => {
|
|
|
79
79
|
{
|
|
80
80
|
id: 's1',
|
|
81
81
|
name: 'First',
|
|
82
|
-
|
|
82
|
+
trialId: 'ws-1',
|
|
83
83
|
instrumentName: 'Stub',
|
|
84
84
|
config: {},
|
|
85
85
|
state: {},
|
|
@@ -99,7 +99,7 @@ const stubInstrument: Instrument = {
|
|
|
99
99
|
render: () => null,
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
const stubRecord:
|
|
102
|
+
const stubRecord: TrialRecord = {
|
|
103
103
|
id: 'ws-1',
|
|
104
104
|
instrumentName: 'Stub',
|
|
105
105
|
config: {},
|
|
@@ -108,7 +108,7 @@ const stubRecord: WorkspaceRecord = {
|
|
|
108
108
|
undoStack: { past: [], future: [] },
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
type ChromeProps = Parameters<typeof
|
|
111
|
+
type ChromeProps = Parameters<typeof TrialChrome>[0];
|
|
112
112
|
|
|
113
113
|
function ChromeHarness({
|
|
114
114
|
children,
|
|
@@ -121,12 +121,12 @@ function ChromeHarness({
|
|
|
121
121
|
});
|
|
122
122
|
const labCtx: LabContextValue = {
|
|
123
123
|
instruments: [stubInstrument],
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
trials: [stubRecord],
|
|
125
|
+
addTrial: vi.fn(),
|
|
126
|
+
cloneTrial: vi.fn(),
|
|
127
|
+
closeTrial: vi.fn(),
|
|
128
|
+
resetTrial: vi.fn(),
|
|
129
|
+
reorderTrials: vi.fn(),
|
|
130
130
|
savedSnapshots: [],
|
|
131
131
|
saveSnapshot: vi.fn(),
|
|
132
132
|
loadSnapshot: vi.fn(),
|
|
@@ -138,21 +138,21 @@ function ChromeHarness({
|
|
|
138
138
|
return (
|
|
139
139
|
<LabStoreContext.Provider value={{ store }}>
|
|
140
140
|
<LabContext.Provider value={labCtx}>
|
|
141
|
-
<
|
|
142
|
-
|
|
141
|
+
<TrialChrome
|
|
142
|
+
trialId="ws-1"
|
|
143
143
|
record={stubRecord}
|
|
144
144
|
instrument={stubInstrument}
|
|
145
|
-
|
|
145
|
+
isLastTrial={false}
|
|
146
146
|
{...props}
|
|
147
147
|
>
|
|
148
148
|
{children ?? <div data-testid="content">content</div>}
|
|
149
|
-
</
|
|
149
|
+
</TrialChrome>
|
|
150
150
|
</LabContext.Provider>
|
|
151
151
|
</LabStoreContext.Provider>
|
|
152
152
|
);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
describe('<
|
|
155
|
+
describe('<TrialChrome>', () => {
|
|
156
156
|
it('renders children in the content area', () => {
|
|
157
157
|
render(<ChromeHarness />);
|
|
158
158
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
|
@@ -172,16 +172,16 @@ describe('<WorkspaceChrome>', () => {
|
|
|
172
172
|
it('Cmd+S triggers saveSnapshot', () => {
|
|
173
173
|
const saveSnapshot = vi.fn();
|
|
174
174
|
render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
|
|
175
|
-
const region = screen.getByRole('region', { name: /
|
|
175
|
+
const region = screen.getByRole('region', { name: /trial/i });
|
|
176
176
|
fireEvent.keyDown(region, { key: 's', metaKey: true });
|
|
177
177
|
expect(saveSnapshot).toHaveBeenCalledWith('ws-1', undefined);
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
-
describe('<Lab> +
|
|
182
|
-
it('Lab provides context for nested
|
|
181
|
+
describe('<Lab> + TrialChrome integration', () => {
|
|
182
|
+
it('Lab provides context for nested TrialChrome', () => {
|
|
183
183
|
render(<Lab instruments={[stubInstrument]} defaultInstrument="Stub" />);
|
|
184
|
-
// Lab seeds a
|
|
184
|
+
// Lab seeds a trial; no chrome rendered without children — sanity check Lab mounts.
|
|
185
185
|
expect(document.querySelector('.lk-lab')).toBeTruthy();
|
|
186
186
|
});
|
|
187
187
|
});
|
|
@@ -9,64 +9,63 @@ import type {
|
|
|
9
9
|
LayerDescriptor,
|
|
10
10
|
PaletteItem,
|
|
11
11
|
RenderContext,
|
|
12
|
-
ViewTransform,
|
|
13
12
|
} from '../instrument/types';
|
|
13
|
+
import { useJob } from '../job/useJob';
|
|
14
14
|
import { useLabContext } from '../lab/LabContext';
|
|
15
15
|
import { LayerList } from '../layers/LayerList';
|
|
16
16
|
import { LabStoreContext } from '../state/context';
|
|
17
17
|
import type { LabStore } from '../state/store';
|
|
18
|
-
import type {
|
|
18
|
+
import type { TrialRecord } from '../state/types';
|
|
19
|
+
import { as2DView, DEFAULT_VIEW } from '../state/view';
|
|
19
20
|
import { createEventBus, type EventBus } from '../undo/eventBus';
|
|
20
21
|
import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
|
|
21
|
-
import type { UndoBindings } from './
|
|
22
|
-
import {
|
|
22
|
+
import type { UndoBindings } from './TrialChrome';
|
|
23
|
+
import { TrialChrome } from './TrialChrome';
|
|
23
24
|
|
|
24
|
-
/** Props for `<
|
|
25
|
-
export interface
|
|
25
|
+
/** Props for `<Trial>`. */
|
|
26
|
+
export interface TrialProps {
|
|
26
27
|
id: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
/** Renders one
|
|
30
|
-
* instrument, and mounts the instrument inside the
|
|
31
|
-
export function
|
|
30
|
+
/** Renders one trial from the lab store: looks up its record and
|
|
31
|
+
* instrument, and mounts the instrument inside the trial chrome. */
|
|
32
|
+
export function Trial({ id }: TrialProps) {
|
|
32
33
|
const lab = useLabContext();
|
|
33
34
|
const storeCtx = useContext(LabStoreContext);
|
|
34
|
-
if (!storeCtx) throw new Error('[labkit] <
|
|
35
|
-
const record = useStore(storeCtx.store, (s) => s.
|
|
35
|
+
if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
|
|
36
|
+
const record = useStore(storeCtx.store, (s) => s.trials.find((w) => w.id === id));
|
|
36
37
|
if (!record) {
|
|
37
|
-
return <div className="lk-
|
|
38
|
+
return <div className="lk-trial lk-trial--unknown">Trial not found: {id}</div>;
|
|
38
39
|
}
|
|
39
40
|
const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
|
|
40
41
|
if (!instrument) {
|
|
41
42
|
return (
|
|
42
|
-
<div className="lk-
|
|
43
|
-
Unknown instrument: {record.instrumentName}
|
|
44
|
-
</div>
|
|
43
|
+
<div className="lk-trial lk-trial--unknown">Unknown instrument: {record.instrumentName}</div>
|
|
45
44
|
);
|
|
46
45
|
}
|
|
47
46
|
return (
|
|
48
|
-
<
|
|
47
|
+
<TrialRuntime
|
|
49
48
|
record={record}
|
|
50
49
|
instrument={instrument}
|
|
51
50
|
store={storeCtx.store}
|
|
52
|
-
isLast={lab.
|
|
51
|
+
isLast={lab.trials.length <= 1}
|
|
53
52
|
/>
|
|
54
53
|
);
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
interface
|
|
58
|
-
record:
|
|
56
|
+
interface TrialRuntimeProps {
|
|
57
|
+
record: TrialRecord;
|
|
59
58
|
instrument: Instrument;
|
|
60
59
|
store: LabStore;
|
|
61
60
|
isLast: boolean;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
function
|
|
63
|
+
function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps) {
|
|
65
64
|
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
65
|
+
const updateTrialState = useStore(store, (s) => s.updateTrialState);
|
|
66
|
+
const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
|
|
67
|
+
const updateTrialView = useStore(store, (s) => s.updateTrialView);
|
|
68
|
+
const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
|
|
70
69
|
|
|
71
70
|
const busRef = useRef<EventBus | null>(null);
|
|
72
71
|
if (busRef.current === null) busRef.current = createEventBus();
|
|
@@ -81,39 +80,59 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
81
80
|
|
|
82
81
|
const snapshotIfNeeded = (event: string): void => {
|
|
83
82
|
if (!undoCap || !undoEvents.has(event)) return;
|
|
84
|
-
const current = store.getState().
|
|
83
|
+
const current = store.getState().trials.find((w) => w.id === record.id);
|
|
85
84
|
if (!current) return;
|
|
86
85
|
const snap = structuredClone(current.state);
|
|
87
|
-
|
|
86
|
+
updateTrialUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
|
|
88
87
|
};
|
|
89
88
|
|
|
90
|
-
const setView = (v:
|
|
89
|
+
const setView = (v: unknown): void => updateTrialView(record.id, v);
|
|
90
|
+
|
|
91
|
+
// `CanvasStack` and the zoom controls are 2D; a trial holding another view shape
|
|
92
|
+
// gets the default here and simply never renders them.
|
|
93
|
+
const view2d = as2DView(record.view);
|
|
94
|
+
|
|
95
|
+
const jobCap = instrument.job;
|
|
96
|
+
// Hooks cannot be conditional, so a trial without the capability still calls
|
|
97
|
+
// this — with a runner that yields nothing and is never started.
|
|
98
|
+
const job = useJob({
|
|
99
|
+
capability: jobCap ?? { run: async function* () {}, onItem: (_i, st) => st },
|
|
100
|
+
config: record.config,
|
|
101
|
+
state: record.state,
|
|
102
|
+
setState: (next) => updateTrialState(record.id, next as never),
|
|
103
|
+
});
|
|
91
104
|
|
|
92
105
|
const renderCtx: RenderContext<unknown, unknown> = {
|
|
93
106
|
state: record.state,
|
|
94
107
|
config: record.config,
|
|
95
108
|
setState: (next) => {
|
|
96
109
|
snapshotIfNeeded('state.change');
|
|
97
|
-
|
|
110
|
+
updateTrialState(record.id, next);
|
|
98
111
|
bus.emit('state.change');
|
|
99
112
|
},
|
|
100
113
|
setConfig: (key, value) => {
|
|
101
114
|
const evt = `config.change:${String(key)}`;
|
|
102
115
|
snapshotIfNeeded('config.change');
|
|
103
116
|
snapshotIfNeeded(evt);
|
|
104
|
-
|
|
117
|
+
updateTrialConfig(record.id, key as never, value as never);
|
|
105
118
|
bus.emit('config.change');
|
|
106
119
|
bus.emit(evt);
|
|
107
120
|
},
|
|
108
|
-
|
|
121
|
+
trial: {
|
|
109
122
|
id: record.id,
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
view: record.view,
|
|
124
|
+
setView,
|
|
125
|
+
zoom: view2d?.zoom ?? 1,
|
|
126
|
+
setZoom: (z) => {
|
|
127
|
+
if (!view2d) return;
|
|
128
|
+
updateTrialView(record.id, { ...view2d, zoom: z });
|
|
129
|
+
},
|
|
112
130
|
},
|
|
113
131
|
emit: (event) => {
|
|
114
132
|
snapshotIfNeeded(event);
|
|
115
133
|
bus.emit(event);
|
|
116
134
|
},
|
|
135
|
+
job: jobCap ? job : undefined,
|
|
117
136
|
};
|
|
118
137
|
|
|
119
138
|
const undoBindings: UndoBindings | undefined = undoCap
|
|
@@ -123,14 +142,14 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
123
142
|
undo: () => {
|
|
124
143
|
const result = undoUndo(record.undoStack, structuredClone(record.state));
|
|
125
144
|
if (!result) return;
|
|
126
|
-
|
|
127
|
-
|
|
145
|
+
updateTrialState(record.id, result.snapshot as never);
|
|
146
|
+
updateTrialUndoStack(record.id, result.stack);
|
|
128
147
|
},
|
|
129
148
|
redo: () => {
|
|
130
149
|
const result = undoRedo(record.undoStack, structuredClone(record.state));
|
|
131
150
|
if (!result) return;
|
|
132
|
-
|
|
133
|
-
|
|
151
|
+
updateTrialState(record.id, result.snapshot as never);
|
|
152
|
+
updateTrialUndoStack(record.id, result.stack);
|
|
134
153
|
},
|
|
135
154
|
}
|
|
136
155
|
: undefined;
|
|
@@ -144,8 +163,13 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
144
163
|
return ordered.map((layer) => ({
|
|
145
164
|
id: layer.id,
|
|
146
165
|
visible: layerVisibility[layer.id] !== false,
|
|
147
|
-
render: (ctx, view) =>
|
|
148
|
-
|
|
166
|
+
render: (ctx, view) => {
|
|
167
|
+
// Camera applied here, so a layer draws in world coordinates. `zoom`
|
|
168
|
+
// stays in the args for line widths, which must not scale with it.
|
|
169
|
+
ctx.translate(view.pan.x, view.pan.y);
|
|
170
|
+
ctx.scale(view.zoom, view.zoom);
|
|
171
|
+
layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
|
|
172
|
+
},
|
|
149
173
|
}));
|
|
150
174
|
}, [instrument.canvas, record.state, record.config, layerVisibility, layerOrder]);
|
|
151
175
|
|
|
@@ -157,12 +181,12 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
157
181
|
const dragDropResult = useDragDrop({
|
|
158
182
|
capability: instrument.dragDrop ?? { palette: [], onDrop: (_p, _i, s) => s },
|
|
159
183
|
canvasContainerRef,
|
|
160
|
-
view:
|
|
184
|
+
view: view2d ?? DEFAULT_VIEW,
|
|
161
185
|
state: record.state,
|
|
162
186
|
config: record.config,
|
|
163
187
|
setState: (next) => {
|
|
164
188
|
snapshotIfNeeded('canvas.itemAdded');
|
|
165
|
-
|
|
189
|
+
updateTrialState(record.id, next as never);
|
|
166
190
|
},
|
|
167
191
|
emit: (evt) => {
|
|
168
192
|
snapshotIfNeeded(evt);
|
|
@@ -206,10 +230,16 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
206
230
|
body = (
|
|
207
231
|
<div
|
|
208
232
|
ref={canvasContainerRef}
|
|
209
|
-
className="lk-
|
|
233
|
+
className="lk-trial__canvas-host"
|
|
210
234
|
style={{ width: '100%', height: '100%', position: 'relative' }}
|
|
211
235
|
>
|
|
212
|
-
<CanvasStack
|
|
236
|
+
<CanvasStack
|
|
237
|
+
layers={layersWithFeedback}
|
|
238
|
+
view={view2d ?? DEFAULT_VIEW}
|
|
239
|
+
onViewChange={setView}
|
|
240
|
+
>
|
|
241
|
+
{instrument.render(renderCtx)}
|
|
242
|
+
</CanvasStack>
|
|
213
243
|
<DragOverlay drag={dragDropResult.drag} />
|
|
214
244
|
</div>
|
|
215
245
|
);
|
|
@@ -239,11 +269,12 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
239
269
|
) : null;
|
|
240
270
|
|
|
241
271
|
return (
|
|
242
|
-
<
|
|
243
|
-
|
|
272
|
+
<TrialChrome
|
|
273
|
+
job={jobCap ? job : undefined}
|
|
274
|
+
trialId={record.id}
|
|
244
275
|
record={record}
|
|
245
276
|
instrument={instrument}
|
|
246
|
-
|
|
277
|
+
isLastTrial={isLast}
|
|
247
278
|
undoBindings={undoBindings}
|
|
248
279
|
sidebarExtras={
|
|
249
280
|
<>
|
|
@@ -253,6 +284,6 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
253
284
|
}
|
|
254
285
|
>
|
|
255
286
|
{body}
|
|
256
|
-
</
|
|
287
|
+
</TrialChrome>
|
|
257
288
|
);
|
|
258
289
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { type KeyboardEvent, type ReactNode, useContext, useMemo } from 'react';
|
|
2
2
|
import { useStore } from 'zustand/react';
|
|
3
3
|
import type { Instrument } from '../instrument/types';
|
|
4
|
+
import type { JobHandle } from '../job/types';
|
|
4
5
|
import { useLabContext } from '../lab/LabContext';
|
|
5
6
|
import { LabStoreContext } from '../state/context';
|
|
6
|
-
import type {
|
|
7
|
+
import type { TrialRecord } from '../state/types';
|
|
8
|
+
import { as2DView } from '../state/view';
|
|
7
9
|
import { DefaultSidebar } from './DefaultSidebar';
|
|
8
10
|
import { DefaultStatusBar } from './DefaultStatusBar';
|
|
9
11
|
import { DefaultToolbar } from './DefaultToolbar';
|
|
@@ -11,9 +13,9 @@ import type {
|
|
|
11
13
|
SidebarSlot,
|
|
12
14
|
StatusBarSlot,
|
|
13
15
|
ToolbarSlot,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
TrialSidebarContext,
|
|
17
|
+
TrialStatusBarContext,
|
|
18
|
+
TrialToolbarContext,
|
|
17
19
|
} from './slotTypes';
|
|
18
20
|
|
|
19
21
|
export interface UndoBindings {
|
|
@@ -23,16 +25,18 @@ export interface UndoBindings {
|
|
|
23
25
|
redo: () => void;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/** Props for `<
|
|
27
|
-
export interface
|
|
28
|
-
|
|
29
|
-
record:
|
|
28
|
+
/** Props for `<TrialChrome>`. */
|
|
29
|
+
export interface TrialChromeProps {
|
|
30
|
+
trialId: string;
|
|
31
|
+
record: TrialRecord;
|
|
30
32
|
instrument: Instrument;
|
|
31
|
-
|
|
33
|
+
isLastTrial: boolean;
|
|
32
34
|
toolbar?: ToolbarSlot;
|
|
33
35
|
sidebar?: SidebarSlot;
|
|
34
36
|
statusBar?: StatusBarSlot;
|
|
35
37
|
undoBindings?: UndoBindings;
|
|
38
|
+
/** Supplied when the instrument declares a `job`. */
|
|
39
|
+
job?: JobHandle;
|
|
36
40
|
sidebarExtras?: ReactNode;
|
|
37
41
|
children: ReactNode;
|
|
38
42
|
}
|
|
@@ -40,56 +44,62 @@ export interface WorkspaceChromeProps {
|
|
|
40
44
|
/** The frame around a running instrument — toolbar, sidebar, status bar —
|
|
41
45
|
* each replaceable by a slot. Assembles the slot contexts and wires the undo
|
|
42
46
|
* keyboard shortcuts. */
|
|
43
|
-
export function
|
|
44
|
-
|
|
47
|
+
export function TrialChrome({
|
|
48
|
+
trialId,
|
|
45
49
|
record,
|
|
46
50
|
instrument,
|
|
47
|
-
|
|
51
|
+
isLastTrial,
|
|
48
52
|
toolbar,
|
|
49
53
|
sidebar,
|
|
50
54
|
statusBar,
|
|
51
55
|
undoBindings,
|
|
56
|
+
job,
|
|
52
57
|
sidebarExtras,
|
|
53
58
|
children,
|
|
54
|
-
}:
|
|
59
|
+
}: TrialChromeProps) {
|
|
55
60
|
const lab = useLabContext();
|
|
56
61
|
const storeCtx = useContext(LabStoreContext);
|
|
57
|
-
if (!storeCtx) throw new Error('[labkit]
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
62
|
+
if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
|
|
63
|
+
const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
|
|
64
|
+
const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
|
|
65
|
+
const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
|
|
61
66
|
|
|
62
|
-
|
|
67
|
+
// The trial view is opaque to labkit, so the zoom chrome asks for the 2D shape
|
|
68
|
+
// and goes inert when the trial holds something else — an orbit, say.
|
|
69
|
+
const view2d = as2DView(record.view);
|
|
70
|
+
|
|
71
|
+
const toolbarCtx = useMemo<TrialToolbarContext>(() => {
|
|
63
72
|
const setZoom = (z: number): void => {
|
|
64
|
-
|
|
73
|
+
if (!view2d) return;
|
|
74
|
+
updateTrialView(trialId, { ...view2d, zoom: z });
|
|
65
75
|
};
|
|
66
76
|
return {
|
|
67
|
-
|
|
77
|
+
trialId,
|
|
68
78
|
instrumentName: record.instrumentName,
|
|
69
79
|
hasUndo: instrument.undo != null,
|
|
70
80
|
canUndo: undoBindings?.canUndo ?? false,
|
|
71
81
|
canRedo: undoBindings?.canRedo ?? false,
|
|
72
82
|
undo: undoBindings?.undo ?? (() => {}),
|
|
73
83
|
redo: undoBindings?.redo ?? (() => {}),
|
|
74
|
-
zoom:
|
|
84
|
+
zoom: view2d?.zoom ?? 1,
|
|
75
85
|
setZoom,
|
|
76
|
-
zoomIn: () => setZoom(
|
|
77
|
-
zoomOut: () => setZoom(
|
|
86
|
+
zoomIn: () => setZoom((view2d?.zoom ?? 1) * 1.25),
|
|
87
|
+
zoomOut: () => setZoom((view2d?.zoom ?? 1) * 0.8),
|
|
78
88
|
resetZoom: () => setZoom(1),
|
|
79
89
|
hasCanvas: instrument.canvas != null,
|
|
80
|
-
savedSnapshots: lab.savedSnapshots.filter((s) => s.
|
|
81
|
-
saveSnapshot: (name) => lab.saveSnapshot(
|
|
82
|
-
loadSnapshot: (snapshotId) => lab.loadSnapshot(
|
|
83
|
-
clone: () => lab.
|
|
84
|
-
reset: () => lab.
|
|
85
|
-
close: () => lab.
|
|
86
|
-
|
|
90
|
+
savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
|
|
91
|
+
saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
|
|
92
|
+
loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
|
|
93
|
+
clone: () => lab.cloneTrial(trialId),
|
|
94
|
+
reset: () => lab.resetTrial(trialId),
|
|
95
|
+
close: () => lab.closeTrial(trialId),
|
|
96
|
+
isLastTrial,
|
|
87
97
|
};
|
|
88
|
-
}, [
|
|
98
|
+
}, [trialId, record, instrument, lab, isLastTrial, updateTrialView, undoBindings, view2d]);
|
|
89
99
|
|
|
90
|
-
const sidebarCtx = useMemo<
|
|
100
|
+
const sidebarCtx = useMemo<TrialSidebarContext>(
|
|
91
101
|
() => ({
|
|
92
|
-
|
|
102
|
+
trialId,
|
|
93
103
|
instrumentName: record.instrumentName,
|
|
94
104
|
configFields: instrument.configSchema?.() ?? [],
|
|
95
105
|
config: record.config,
|
|
@@ -100,21 +110,21 @@ export function WorkspaceChrome({
|
|
|
100
110
|
`[labkit] setConfig: unknown key "${key}" for instrument "${record.instrumentName}"`,
|
|
101
111
|
);
|
|
102
112
|
}
|
|
103
|
-
|
|
113
|
+
updateTrialConfig(trialId, key as never, value as never);
|
|
104
114
|
if (instrument.onConfigChange) {
|
|
105
115
|
const nextConfig = { ...prevConfig, [key]: value };
|
|
106
116
|
const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
|
|
107
|
-
|
|
117
|
+
updateTrialState(trialId, nextState as never);
|
|
108
118
|
}
|
|
109
119
|
},
|
|
110
120
|
}),
|
|
111
|
-
[
|
|
121
|
+
[trialId, record, instrument, updateTrialConfig, updateTrialState],
|
|
112
122
|
);
|
|
113
123
|
|
|
114
|
-
const statusCtx:
|
|
115
|
-
|
|
124
|
+
const statusCtx: TrialStatusBarContext = {
|
|
125
|
+
trialId,
|
|
116
126
|
instrumentName: record.instrumentName,
|
|
117
|
-
zoom:
|
|
127
|
+
zoom: view2d ? view2d.zoom : null,
|
|
118
128
|
};
|
|
119
129
|
|
|
120
130
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
|
|
@@ -132,21 +142,38 @@ export function WorkspaceChrome({
|
|
|
132
142
|
|
|
133
143
|
return (
|
|
134
144
|
<section
|
|
135
|
-
className="lk-
|
|
136
|
-
aria-label={`
|
|
145
|
+
className="lk-trial"
|
|
146
|
+
aria-label={`Trial ${record.instrumentName}`}
|
|
137
147
|
onKeyDown={handleKeyDown}
|
|
138
148
|
>
|
|
139
|
-
<div className="lk-
|
|
149
|
+
<div className="lk-trial__toolbar">
|
|
140
150
|
{toolbar ? toolbar(toolbarCtx) : <DefaultToolbar ctx={toolbarCtx} />}
|
|
141
151
|
</div>
|
|
142
|
-
<div className="lk-
|
|
143
|
-
<div className="lk-
|
|
152
|
+
<div className="lk-trial__body">
|
|
153
|
+
<div className="lk-trial__sidebar">
|
|
144
154
|
{sidebar ? sidebar(sidebarCtx) : <DefaultSidebar ctx={sidebarCtx} />}
|
|
145
155
|
{sidebarExtras}
|
|
146
156
|
</div>
|
|
147
|
-
<div className="lk-
|
|
157
|
+
<div className="lk-trial__content">{children}</div>
|
|
148
158
|
</div>
|
|
149
|
-
<div className="lk-
|
|
159
|
+
<div className="lk-trial__status">
|
|
160
|
+
{job ? (
|
|
161
|
+
<div className="lk-trial__job">
|
|
162
|
+
<span className="lk-trial__job-count">
|
|
163
|
+
{job.done}
|
|
164
|
+
{job.total === null ? '' : ` / ${job.total}`}
|
|
165
|
+
</span>
|
|
166
|
+
{job.failures.length > 0 ? (
|
|
167
|
+
<span className="lk-trial__job-failures">{job.failures.length} failed</span>
|
|
168
|
+
) : null}
|
|
169
|
+
{job.error ? <span className="lk-trial__job-error">{job.error}</span> : null}
|
|
170
|
+
{job.status === 'running' ? (
|
|
171
|
+
<button type="button" className="lk-trial__job-cancel" onClick={job.cancel}>
|
|
172
|
+
Cancel
|
|
173
|
+
</button>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
) : null}
|
|
150
177
|
{statusBar ? statusBar(statusCtx) : <DefaultStatusBar ctx={statusCtx} />}
|
|
151
178
|
</div>
|
|
152
179
|
</section>
|