@weasel-js/labkit 1.0.3 → 1.1.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 +6 -6
- package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
- package/dist/chunk-NRKWVTVT.js.map +1 -0
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +197 -148
- package/dist/index.js +275 -161
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -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 +10 -3
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +14 -14
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +179 -14
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +2 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +56 -44
- package/src/lab/LabContext.ts +12 -11
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/Workspace.less +40 -0
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
- package/src/lab/Workspace.test.tsx +114 -0
- package/src/lab/Workspace.tsx +196 -0
- package/src/lab/index.ts +2 -4
- 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 +23 -31
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -40
- package/src/state/store.ts +166 -90
- package/src/state/types.ts +30 -10
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/test-setup.ts +19 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -12
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
- 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/chunk-3WPOGKUP.js.map +0 -1
- package/dist/chunk-73PXCRCR.js.map +0 -1
- package/dist/chunk-N5KTQKQA.js.map +0 -1
- package/dist/chunk-T7OKNJTY.js +0 -351
- package/dist/chunk-T7OKNJTY.js.map +0 -1
- package/src/lab/WorkspaceGrid.less +0 -8
- package/src/lab/WorkspaceGrid.test.tsx +0 -40
- package/src/lab/WorkspaceGrid.tsx +0 -22
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -13
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/workspaceOps.ts +0 -86
|
@@ -3,7 +3,7 @@ import { useStore } from 'zustand/react';
|
|
|
3
3
|
import type { Instrument } from '../instrument/types';
|
|
4
4
|
import { useLabContext } from '../lab/LabContext';
|
|
5
5
|
import { LabStoreContext } from '../state/context';
|
|
6
|
-
import type {
|
|
6
|
+
import type { TrialRecord } from '../state/types';
|
|
7
7
|
import { DefaultSidebar } from './DefaultSidebar';
|
|
8
8
|
import { DefaultStatusBar } from './DefaultStatusBar';
|
|
9
9
|
import { DefaultToolbar } from './DefaultToolbar';
|
|
@@ -11,9 +11,9 @@ import type {
|
|
|
11
11
|
SidebarSlot,
|
|
12
12
|
StatusBarSlot,
|
|
13
13
|
ToolbarSlot,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
TrialSidebarContext,
|
|
15
|
+
TrialStatusBarContext,
|
|
16
|
+
TrialToolbarContext,
|
|
17
17
|
} from './slotTypes';
|
|
18
18
|
|
|
19
19
|
export interface UndoBindings {
|
|
@@ -23,12 +23,12 @@ export interface UndoBindings {
|
|
|
23
23
|
redo: () => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** Props for `<
|
|
27
|
-
export interface
|
|
28
|
-
|
|
29
|
-
record:
|
|
26
|
+
/** Props for `<TrialChrome>`. */
|
|
27
|
+
export interface TrialChromeProps {
|
|
28
|
+
trialId: string;
|
|
29
|
+
record: TrialRecord;
|
|
30
30
|
instrument: Instrument;
|
|
31
|
-
|
|
31
|
+
isLastTrial: boolean;
|
|
32
32
|
toolbar?: ToolbarSlot;
|
|
33
33
|
sidebar?: SidebarSlot;
|
|
34
34
|
statusBar?: StatusBarSlot;
|
|
@@ -40,31 +40,31 @@ export interface WorkspaceChromeProps {
|
|
|
40
40
|
/** The frame around a running instrument — toolbar, sidebar, status bar —
|
|
41
41
|
* each replaceable by a slot. Assembles the slot contexts and wires the undo
|
|
42
42
|
* keyboard shortcuts. */
|
|
43
|
-
export function
|
|
44
|
-
|
|
43
|
+
export function TrialChrome({
|
|
44
|
+
trialId,
|
|
45
45
|
record,
|
|
46
46
|
instrument,
|
|
47
|
-
|
|
47
|
+
isLastTrial,
|
|
48
48
|
toolbar,
|
|
49
49
|
sidebar,
|
|
50
50
|
statusBar,
|
|
51
51
|
undoBindings,
|
|
52
52
|
sidebarExtras,
|
|
53
53
|
children,
|
|
54
|
-
}:
|
|
54
|
+
}: TrialChromeProps) {
|
|
55
55
|
const lab = useLabContext();
|
|
56
56
|
const storeCtx = useContext(LabStoreContext);
|
|
57
|
-
if (!storeCtx) throw new Error('[labkit]
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
57
|
+
if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
|
|
58
|
+
const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
|
|
59
|
+
const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
|
|
60
|
+
const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
|
|
61
61
|
|
|
62
|
-
const toolbarCtx = useMemo<
|
|
62
|
+
const toolbarCtx = useMemo<TrialToolbarContext>(() => {
|
|
63
63
|
const setZoom = (z: number): void => {
|
|
64
|
-
|
|
64
|
+
updateTrialView(trialId, { ...record.view, zoom: z });
|
|
65
65
|
};
|
|
66
66
|
return {
|
|
67
|
-
|
|
67
|
+
trialId,
|
|
68
68
|
instrumentName: record.instrumentName,
|
|
69
69
|
hasUndo: instrument.undo != null,
|
|
70
70
|
canUndo: undoBindings?.canUndo ?? false,
|
|
@@ -77,19 +77,19 @@ export function WorkspaceChrome({
|
|
|
77
77
|
zoomOut: () => setZoom(record.view.zoom * 0.8),
|
|
78
78
|
resetZoom: () => setZoom(1),
|
|
79
79
|
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
|
-
|
|
80
|
+
savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
|
|
81
|
+
saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
|
|
82
|
+
loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
|
|
83
|
+
clone: () => lab.cloneTrial(trialId),
|
|
84
|
+
reset: () => lab.resetTrial(trialId),
|
|
85
|
+
close: () => lab.closeTrial(trialId),
|
|
86
|
+
isLastTrial,
|
|
87
87
|
};
|
|
88
|
-
}, [
|
|
88
|
+
}, [trialId, record, instrument, lab, isLastTrial, updateTrialView, undoBindings]);
|
|
89
89
|
|
|
90
|
-
const sidebarCtx = useMemo<
|
|
90
|
+
const sidebarCtx = useMemo<TrialSidebarContext>(
|
|
91
91
|
() => ({
|
|
92
|
-
|
|
92
|
+
trialId,
|
|
93
93
|
instrumentName: record.instrumentName,
|
|
94
94
|
configFields: instrument.configSchema?.() ?? [],
|
|
95
95
|
config: record.config,
|
|
@@ -100,19 +100,19 @@ export function WorkspaceChrome({
|
|
|
100
100
|
`[labkit] setConfig: unknown key "${key}" for instrument "${record.instrumentName}"`,
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
|
-
|
|
103
|
+
updateTrialConfig(trialId, key as never, value as never);
|
|
104
104
|
if (instrument.onConfigChange) {
|
|
105
105
|
const nextConfig = { ...prevConfig, [key]: value };
|
|
106
106
|
const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
|
|
107
|
-
|
|
107
|
+
updateTrialState(trialId, nextState as never);
|
|
108
108
|
}
|
|
109
109
|
},
|
|
110
110
|
}),
|
|
111
|
-
[
|
|
111
|
+
[trialId, record, instrument, updateTrialConfig, updateTrialState],
|
|
112
112
|
);
|
|
113
113
|
|
|
114
|
-
const statusCtx:
|
|
115
|
-
|
|
114
|
+
const statusCtx: TrialStatusBarContext = {
|
|
115
|
+
trialId,
|
|
116
116
|
instrumentName: record.instrumentName,
|
|
117
117
|
zoom: record.view.zoom,
|
|
118
118
|
};
|
|
@@ -132,21 +132,21 @@ export function WorkspaceChrome({
|
|
|
132
132
|
|
|
133
133
|
return (
|
|
134
134
|
<section
|
|
135
|
-
className="lk-
|
|
136
|
-
aria-label={`
|
|
135
|
+
className="lk-trial"
|
|
136
|
+
aria-label={`Trial ${record.instrumentName}`}
|
|
137
137
|
onKeyDown={handleKeyDown}
|
|
138
138
|
>
|
|
139
|
-
<div className="lk-
|
|
139
|
+
<div className="lk-trial__toolbar">
|
|
140
140
|
{toolbar ? toolbar(toolbarCtx) : <DefaultToolbar ctx={toolbarCtx} />}
|
|
141
141
|
</div>
|
|
142
|
-
<div className="lk-
|
|
143
|
-
<div className="lk-
|
|
142
|
+
<div className="lk-trial__body">
|
|
143
|
+
<div className="lk-trial__sidebar">
|
|
144
144
|
{sidebar ? sidebar(sidebarCtx) : <DefaultSidebar ctx={sidebarCtx} />}
|
|
145
145
|
{sidebarExtras}
|
|
146
146
|
</div>
|
|
147
|
-
<div className="lk-
|
|
147
|
+
<div className="lk-trial__content">{children}</div>
|
|
148
148
|
</div>
|
|
149
|
-
<div className="lk-
|
|
149
|
+
<div className="lk-trial__status">
|
|
150
150
|
{statusBar ? statusBar(statusCtx) : <DefaultStatusBar ctx={statusCtx} />}
|
|
151
151
|
</div>
|
|
152
152
|
</section>
|
|
@@ -8,17 +8,18 @@ export type {
|
|
|
8
8
|
SidebarSlot,
|
|
9
9
|
StatusBarSlot,
|
|
10
10
|
ToolbarSlot,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
TrialSidebarContext,
|
|
12
|
+
TrialStatusBarContext,
|
|
13
|
+
TrialToolbarContext,
|
|
14
14
|
} from './slotTypes';
|
|
15
|
-
export type {
|
|
16
|
-
export {
|
|
17
|
-
export type {
|
|
18
|
-
export {
|
|
15
|
+
export type { TrialProps } from './Trial';
|
|
16
|
+
export { Trial } from './Trial';
|
|
17
|
+
export type { TrialChromeProps } from './TrialChrome';
|
|
18
|
+
export { TrialChrome } from './TrialChrome';
|
|
19
19
|
export {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
addTrial,
|
|
21
|
+
cloneTrial,
|
|
22
|
+
closeTrial,
|
|
23
|
+
reorderTrials,
|
|
24
|
+
resetTrial,
|
|
25
|
+
} from './trialOps';
|
|
@@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import type { ConfigField } from '../controls/types';
|
|
3
3
|
import type { SavedSnapshot } from '../state/types';
|
|
4
4
|
|
|
5
|
-
/** What a
|
|
5
|
+
/** What a trial hands its toolbar: which instrument is running, the undo
|
|
6
6
|
* state and commands, the zoom controls, and the snapshot commands. */
|
|
7
|
-
export interface
|
|
8
|
-
|
|
7
|
+
export interface TrialToolbarContext {
|
|
8
|
+
trialId: string;
|
|
9
9
|
instrumentName: string;
|
|
10
10
|
hasUndo: boolean;
|
|
11
11
|
canUndo: boolean;
|
|
@@ -24,30 +24,30 @@ export interface WorkspaceToolbarContext {
|
|
|
24
24
|
clone: () => void;
|
|
25
25
|
reset: () => void;
|
|
26
26
|
close: () => void;
|
|
27
|
-
|
|
27
|
+
isLastTrial: boolean;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** What a
|
|
30
|
+
/** What a trial hands its sidebar: the instrument's config schema, its
|
|
31
31
|
* current values, and the setter. */
|
|
32
|
-
export interface
|
|
33
|
-
|
|
32
|
+
export interface TrialSidebarContext {
|
|
33
|
+
trialId: string;
|
|
34
34
|
instrumentName: string;
|
|
35
35
|
configFields: ConfigField[];
|
|
36
36
|
config: unknown;
|
|
37
37
|
setConfig: (key: string, value: unknown) => void;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** What a
|
|
41
|
-
export interface
|
|
42
|
-
|
|
40
|
+
/** What a trial hands its status bar. */
|
|
41
|
+
export interface TrialStatusBarContext {
|
|
42
|
+
trialId: string;
|
|
43
43
|
instrumentName: string;
|
|
44
44
|
zoom: number;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/** Replaces a
|
|
47
|
+
/** Replaces a trial's toolbar. Receives everything the default one uses,
|
|
48
48
|
* so a custom toolbar need not reach into the store. */
|
|
49
|
-
export type ToolbarSlot = (ctx:
|
|
50
|
-
/** Replaces a
|
|
51
|
-
export type SidebarSlot = (ctx:
|
|
52
|
-
/** Replaces a
|
|
53
|
-
export type StatusBarSlot = (ctx:
|
|
49
|
+
export type ToolbarSlot = (ctx: TrialToolbarContext) => ReactNode;
|
|
50
|
+
/** Replaces a trial's sidebar. */
|
|
51
|
+
export type SidebarSlot = (ctx: TrialSidebarContext) => ReactNode;
|
|
52
|
+
/** Replaces a trial's status bar. */
|
|
53
|
+
export type StatusBarSlot = (ctx: TrialStatusBarContext) => ReactNode;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { Instrument } from '../instrument/types';
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
3
|
+
import type { TrialRecord } from '../state/types';
|
|
4
|
+
import { addTrial, cloneTrial, closeTrial, reorderTrials, resetTrial } from './trialOps';
|
|
5
5
|
|
|
6
6
|
interface CounterState {
|
|
7
7
|
count: number;
|
|
@@ -34,9 +34,9 @@ function head<T>(arr: T[]): T {
|
|
|
34
34
|
return first;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
describe('
|
|
37
|
+
describe('addTrial', () => {
|
|
38
38
|
it('appends a new record using defaultConfig and initialState', () => {
|
|
39
|
-
const out =
|
|
39
|
+
const out = addTrial([], instruments, 'Counter');
|
|
40
40
|
expect(out).toHaveLength(1);
|
|
41
41
|
expect(out[0]?.instrumentName).toBe('Counter');
|
|
42
42
|
expect(out[0]?.config).toEqual({ step: 1 });
|
|
@@ -44,38 +44,38 @@ describe('addWorkspace', () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it('assigns a unique id', () => {
|
|
47
|
-
const a =
|
|
48
|
-
const b =
|
|
47
|
+
const a = addTrial([], instruments, 'Counter');
|
|
48
|
+
const b = addTrial(a, instruments, 'Counter');
|
|
49
49
|
expect(b).toHaveLength(2);
|
|
50
50
|
expect(b[0]?.id).not.toBe(b[1]?.id);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it('uses canvas.initialView when present', () => {
|
|
54
|
-
const out =
|
|
54
|
+
const out = addTrial([], instruments, 'CounterCanvas');
|
|
55
55
|
expect(out[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
it('defaults view when canvas absent', () => {
|
|
59
|
-
const out =
|
|
59
|
+
const out = addTrial([], instruments, 'Counter');
|
|
60
60
|
expect(out[0]?.view).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
it('throws when instrumentName is unknown', () => {
|
|
64
|
-
expect(() =>
|
|
64
|
+
expect(() => addTrial([], instruments, 'Missing')).toThrow();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it('does not mutate input array', () => {
|
|
68
|
-
const arr:
|
|
69
|
-
|
|
68
|
+
const arr: TrialRecord[] = [];
|
|
69
|
+
addTrial(arr, instruments, 'Counter');
|
|
70
70
|
expect(arr).toHaveLength(0);
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
describe('
|
|
74
|
+
describe('cloneTrial', () => {
|
|
75
75
|
it('inserts clone immediately after source with new id', () => {
|
|
76
|
-
const a =
|
|
76
|
+
const a = addTrial([], instruments, 'Counter');
|
|
77
77
|
const sourceId = head(a).id;
|
|
78
|
-
const cloned =
|
|
78
|
+
const cloned = cloneTrial(a, sourceId);
|
|
79
79
|
expect(cloned).toHaveLength(2);
|
|
80
80
|
expect(cloned[0]?.id).toBe(sourceId);
|
|
81
81
|
expect(cloned[1]?.id).not.toBe(sourceId);
|
|
@@ -83,60 +83,87 @@ describe('cloneWorkspace', () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
it('deep-copies config/state/view', () => {
|
|
86
|
-
const a =
|
|
87
|
-
const cloned =
|
|
86
|
+
const a = addTrial([], instruments, 'Counter');
|
|
87
|
+
const cloned = cloneTrial(a, head(a).id);
|
|
88
88
|
expect(cloned[1]?.config).not.toBe(cloned[0]?.config);
|
|
89
89
|
expect(cloned[1]?.state).not.toBe(cloned[0]?.state);
|
|
90
90
|
expect(cloned[1]?.view).not.toBe(cloned[0]?.view);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
it('does not mutate input', () => {
|
|
94
|
-
const a =
|
|
94
|
+
const a = addTrial([], instruments, 'Counter');
|
|
95
95
|
const before = a.length;
|
|
96
|
-
|
|
96
|
+
cloneTrial(a, head(a).id);
|
|
97
97
|
expect(a).toHaveLength(before);
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
describe('
|
|
102
|
-
it('removes the
|
|
103
|
-
let arr =
|
|
104
|
-
arr =
|
|
105
|
-
const closed =
|
|
101
|
+
describe('closeTrial', () => {
|
|
102
|
+
it('removes the trial with the given id', () => {
|
|
103
|
+
let arr = addTrial([], instruments, 'Counter');
|
|
104
|
+
arr = addTrial(arr, instruments, 'Counter');
|
|
105
|
+
const closed = closeTrial(arr, head(arr).id);
|
|
106
106
|
expect(closed).toHaveLength(1);
|
|
107
107
|
expect(closed[0]?.id).toBe(arr[1]?.id);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it('is a no-op when only one
|
|
111
|
-
const arr =
|
|
112
|
-
const closed =
|
|
110
|
+
it('is a no-op when only one trial remains', () => {
|
|
111
|
+
const arr = addTrial([], instruments, 'Counter');
|
|
112
|
+
const closed = closeTrial(arr, head(arr).id);
|
|
113
113
|
expect(closed).toEqual(arr);
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
describe('
|
|
117
|
+
describe('resetTrial', () => {
|
|
118
118
|
it('resets config and state to defaults; preserves instrumentName', () => {
|
|
119
|
-
const arr =
|
|
119
|
+
const arr = addTrial([], instruments, 'Counter');
|
|
120
120
|
const id = head(arr).id;
|
|
121
121
|
head(arr).config = { step: 99 } as CounterConfig;
|
|
122
122
|
head(arr).state = { count: 42 } as CounterState;
|
|
123
|
-
const reset =
|
|
123
|
+
const reset = resetTrial(arr, id, instruments);
|
|
124
124
|
expect(reset[0]?.config).toEqual({ step: 1 });
|
|
125
125
|
expect(reset[0]?.state).toEqual({ count: 0 });
|
|
126
126
|
expect(reset[0]?.instrumentName).toBe('Counter');
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
it('resets view to canvas.initialView when present', () => {
|
|
130
|
-
const arr =
|
|
130
|
+
const arr = addTrial([], instruments, 'CounterCanvas');
|
|
131
131
|
head(arr).view = { zoom: 9, pan: { x: 9, y: 9 } };
|
|
132
|
-
const reset =
|
|
132
|
+
const reset = resetTrial(arr, head(arr).id, instruments);
|
|
133
133
|
expect(reset[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
it('does not mutate input', () => {
|
|
137
|
-
const arr =
|
|
137
|
+
const arr = addTrial([], instruments, 'Counter');
|
|
138
138
|
head(arr).config = { step: 99 } as CounterConfig;
|
|
139
|
-
|
|
139
|
+
resetTrial(arr, head(arr).id, instruments);
|
|
140
140
|
expect((arr[0]?.config as CounterConfig).step).toBe(99);
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
describe('reorderTrials', () => {
|
|
145
|
+
const ws = (id: string) =>
|
|
146
|
+
({
|
|
147
|
+
id,
|
|
148
|
+
instrumentName: 'T',
|
|
149
|
+
config: {},
|
|
150
|
+
state: {},
|
|
151
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
152
|
+
undoStack: { past: [], future: [] },
|
|
153
|
+
}) as never;
|
|
154
|
+
|
|
155
|
+
it('reorders to match the given ids', () => {
|
|
156
|
+
const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
|
|
157
|
+
expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('drops ids that no longer exist rather than resurrecting them', () => {
|
|
161
|
+
const next = reorderTrials([ws('a'), ws('b')], ['gone', 'b', 'a']);
|
|
162
|
+
expect(next.map((w) => w.id)).toEqual(['b', 'a']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('keeps unmentioned trials, after the named ones', () => {
|
|
166
|
+
const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c']);
|
|
167
|
+
expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Instrument, InstrumentList } from '../instrument/types';
|
|
2
|
+
import type { TrialRecord } from '../state/types';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_VIEW = { zoom: 1, pan: { x: 0, y: 0 } } as const;
|
|
5
|
+
|
|
6
|
+
function findInstrument(instruments: InstrumentList, name: string): Instrument {
|
|
7
|
+
const found = instruments.find((i) => i.name === name);
|
|
8
|
+
if (!found) {
|
|
9
|
+
throw new Error(`[labkit] Unknown instrument: "${name}"`);
|
|
10
|
+
}
|
|
11
|
+
return found;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function initialView(instrument: Instrument): TrialRecord['view'] {
|
|
15
|
+
return instrument.canvas?.initialView
|
|
16
|
+
? structuredClone(instrument.canvas.initialView)
|
|
17
|
+
: { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Append a new trial running `instrumentName`, at that instrument's
|
|
21
|
+
* default config and initial state. */
|
|
22
|
+
export function addTrial(
|
|
23
|
+
trials: TrialRecord[],
|
|
24
|
+
instruments: InstrumentList,
|
|
25
|
+
instrumentName: string,
|
|
26
|
+
): TrialRecord[] {
|
|
27
|
+
const instrument = findInstrument(instruments, instrumentName);
|
|
28
|
+
const config = instrument.defaultConfig();
|
|
29
|
+
const state = instrument.initialState(config);
|
|
30
|
+
const record: TrialRecord = {
|
|
31
|
+
id: crypto.randomUUID(),
|
|
32
|
+
instrumentName,
|
|
33
|
+
config,
|
|
34
|
+
state,
|
|
35
|
+
view: initialView(instrument),
|
|
36
|
+
undoStack: { past: [], future: [] },
|
|
37
|
+
};
|
|
38
|
+
return [...trials, record];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Insert a deep copy of a trial directly after it. The copy starts with
|
|
42
|
+
* an empty undo history — the original's is not shared. */
|
|
43
|
+
export function cloneTrial(trials: TrialRecord[], id: string): TrialRecord[] {
|
|
44
|
+
const sourceIdx = trials.findIndex((w) => w.id === id);
|
|
45
|
+
const source = trials[sourceIdx];
|
|
46
|
+
if (!source) return trials;
|
|
47
|
+
const clone: TrialRecord = {
|
|
48
|
+
...source,
|
|
49
|
+
id: crypto.randomUUID(),
|
|
50
|
+
config: structuredClone(source.config),
|
|
51
|
+
state: structuredClone(source.state),
|
|
52
|
+
view: structuredClone(source.view),
|
|
53
|
+
undoStack: { past: [], future: [] },
|
|
54
|
+
};
|
|
55
|
+
return [...trials.slice(0, sourceIdx + 1), clone, ...trials.slice(sourceIdx + 1)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Remove a trial, unless it is the last one — a lab always has at least
|
|
59
|
+
* one. */
|
|
60
|
+
export function closeTrial(trials: TrialRecord[], id: string): TrialRecord[] {
|
|
61
|
+
if (trials.length <= 1) return trials;
|
|
62
|
+
const next = trials.filter((w) => w.id !== id);
|
|
63
|
+
return next.length === trials.length ? trials : next;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Return a trial to its instrument's defaults, keeping its id and its
|
|
67
|
+
* place in the list. */
|
|
68
|
+
export function resetTrial(
|
|
69
|
+
trials: TrialRecord[],
|
|
70
|
+
id: string,
|
|
71
|
+
instruments: InstrumentList,
|
|
72
|
+
): TrialRecord[] {
|
|
73
|
+
const idx = trials.findIndex((w) => w.id === id);
|
|
74
|
+
const current = trials[idx];
|
|
75
|
+
if (!current) return trials;
|
|
76
|
+
const instrument = findInstrument(instruments, current.instrumentName);
|
|
77
|
+
const config = instrument.defaultConfig();
|
|
78
|
+
const state = instrument.initialState(config);
|
|
79
|
+
const reset: TrialRecord = {
|
|
80
|
+
...current,
|
|
81
|
+
config,
|
|
82
|
+
state,
|
|
83
|
+
view: initialView(instrument),
|
|
84
|
+
};
|
|
85
|
+
return [...trials.slice(0, idx), reset, ...trials.slice(idx + 1)];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reorder to match `ids`. Ids the list doesn't mention keep their relative
|
|
90
|
+
* order at the end, and ids it names that no longer exist are dropped — a
|
|
91
|
+
* reorder that raced a close should not resurrect the closed trial.
|
|
92
|
+
*/
|
|
93
|
+
export function reorderTrials(trials: TrialRecord[], ids: readonly string[]): TrialRecord[] {
|
|
94
|
+
const byId = new Map(trials.map((w) => [w.id, w]));
|
|
95
|
+
const named = ids.map((id) => byId.get(id)).filter((w): w is TrialRecord => w !== undefined);
|
|
96
|
+
const seen = new Set(named.map((w) => w.id));
|
|
97
|
+
const rest = trials.filter((w) => !seen.has(w.id));
|
|
98
|
+
return [...named, ...rest];
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatZoom } from './format';
|
|
3
|
+
|
|
4
|
+
describe('formatZoom', () => {
|
|
5
|
+
it('shows a percentage up to and including 2x', () => {
|
|
6
|
+
expect(formatZoom(0.5)).toBe('50%');
|
|
7
|
+
expect(formatZoom(1)).toBe('100%');
|
|
8
|
+
expect(formatZoom(1.5)).toBe('150%');
|
|
9
|
+
expect(formatZoom(2)).toBe('200%');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('switches to a multiplier above 2x', () => {
|
|
13
|
+
expect(formatZoom(2.5)).toBe('2.5x');
|
|
14
|
+
expect(formatZoom(4)).toBe('4x');
|
|
15
|
+
expect(formatZoom(16)).toBe('16x');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rounds the multiplier to one decimal and drops a trailing zero', () => {
|
|
19
|
+
expect(formatZoom(3.04)).toBe('3x');
|
|
20
|
+
expect(formatZoom(3.06)).toBe('3.1x');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('drops the decimal and groups thousands past 100x', () => {
|
|
24
|
+
expect(formatZoom(100)).toBe('100x');
|
|
25
|
+
expect(formatZoom(1009.74)).toBe('1,010x');
|
|
26
|
+
expect(formatZoom(99.9)).toBe('99.9x');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('passes non-finite zoom through rather than printing NaN%', () => {
|
|
30
|
+
expect(formatZoom(Number.NaN)).toBe('NaN');
|
|
31
|
+
expect(formatZoom(Number.POSITIVE_INFINITY)).toBe('Infinity');
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/ui/format.ts
CHANGED
|
@@ -17,3 +17,14 @@ export function formatNumber(n: number, fractionDigits?: number): string {
|
|
|
17
17
|
export function parseSignedNumber(s: string): number {
|
|
18
18
|
return Number(s.replace(MINUS, '-'));
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/** Zoom for display. Below 2x a percentage reads naturally; past it the
|
|
22
|
+
* numbers get long and a multiplier is what people say out loud, so 250%
|
|
23
|
+
* shows as `2.5x`. Past 100x a tenth is noise, so the decimal is dropped
|
|
24
|
+
* and thousands are grouped: `1009.74` reads as `1,010x`. */
|
|
25
|
+
export function formatZoom(zoom: number): string {
|
|
26
|
+
if (!Number.isFinite(zoom)) return String(zoom);
|
|
27
|
+
if (zoom <= 2) return `${formatNumber(Math.round(zoom * 100))}%`;
|
|
28
|
+
if (zoom >= 100) return `${Math.round(zoom).toLocaleString('en-US')}x`;
|
|
29
|
+
return `${formatNumber(Math.round(zoom * 10) / 10)}x`;
|
|
30
|
+
}
|
|
@@ -45,7 +45,7 @@ export function CurveField({
|
|
|
45
45
|
}: CurveFieldProps) {
|
|
46
46
|
const points: ControlPoint[] = useMemo(() => {
|
|
47
47
|
const out: ControlPoint[] = [];
|
|
48
|
-
for (let i = 0; i + 1 < values.length; i += 2) out.push({ x: values[i]
|
|
48
|
+
for (let i = 0; i + 1 < values.length; i += 2) out.push({ x: values[i], y: values[i + 1] });
|
|
49
49
|
return out;
|
|
50
50
|
}, [values]);
|
|
51
51
|
|
|
@@ -53,9 +53,9 @@ export function CurveField({
|
|
|
53
53
|
(next: ControlPoint[]) => {
|
|
54
54
|
const flat: number[] = new Array(next.length * 2);
|
|
55
55
|
for (let i = 0; i < next.length; i++) {
|
|
56
|
-
const ySnap = Math.round(next[i]
|
|
56
|
+
const ySnap = Math.round(next[i].y / step) * step;
|
|
57
57
|
const y = Math.max(min, Math.min(max, ySnap));
|
|
58
|
-
flat[i * 2] = next[i]
|
|
58
|
+
flat[i * 2] = next[i].x;
|
|
59
59
|
flat[i * 2 + 1] = y;
|
|
60
60
|
}
|
|
61
61
|
dlog('curve-field', 'handleChange', {
|
|
@@ -71,7 +71,7 @@ export function CurveField({
|
|
|
71
71
|
const handleFlip = useCallback(() => {
|
|
72
72
|
const flipped: Array<{ x: number; y: number }> = [];
|
|
73
73
|
for (let i = 0; i + 1 < values.length; i += 2) {
|
|
74
|
-
flipped.push({ x: 1 - values[i]
|
|
74
|
+
flipped.push({ x: 1 - values[i], y: values[i + 1] });
|
|
75
75
|
}
|
|
76
76
|
flipped.sort((a, b) => a.x - b.x);
|
|
77
77
|
const out: number[] = [];
|
|
@@ -83,8 +83,8 @@ export function CurveField({
|
|
|
83
83
|
const mid = (min + max) / 2;
|
|
84
84
|
const flipped: number[] = new Array(values.length);
|
|
85
85
|
for (let i = 0; i + 1 < values.length; i += 2) {
|
|
86
|
-
flipped[i] = values[i]
|
|
87
|
-
flipped[i + 1] = 2 * mid - values[i + 1]
|
|
86
|
+
flipped[i] = values[i];
|
|
87
|
+
flipped[i + 1] = 2 * mid - values[i + 1];
|
|
88
88
|
}
|
|
89
89
|
onChange(flipped);
|
|
90
90
|
}, [values, min, max, onChange]);
|
|
@@ -290,9 +290,9 @@ export const RightSidebarTails: Story = {
|
|
|
290
290
|
render: () => {
|
|
291
291
|
function TailsList() {
|
|
292
292
|
const [tails, setTails] = useState<TailItem[]>([
|
|
293
|
-
{ id: 1, shape: 'classic', accent: TAIL_PALETTE[0]
|
|
294
|
-
{ id: 2, shape: 'bubbles', accent: TAIL_PALETTE[1]
|
|
295
|
-
{ id: 3, shape: 'wavy', accent: TAIL_PALETTE[2]
|
|
293
|
+
{ id: 1, shape: 'classic', accent: TAIL_PALETTE[0] },
|
|
294
|
+
{ id: 2, shape: 'bubbles', accent: TAIL_PALETTE[1] },
|
|
295
|
+
{ id: 3, shape: 'wavy', accent: TAIL_PALETTE[2] },
|
|
296
296
|
]);
|
|
297
297
|
const reorder = (sourceId: number, targetId: number, position: 'before' | 'after') => {
|
|
298
298
|
setTails((prev) => {
|