@weasel-js/labkit 1.0.4 → 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/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-D7EQnnwJ.d.ts → useTrialState-DYe2vUwN.d.ts} +60 -36
- 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-BAPZPDDA.js → chunk-C6GJKPUI.js} +365 -340
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-DJLDIRFN.js} +28 -12
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-NRKWVTVT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-NRKWVTVT.js.map} +1 -1
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +149 -149
- package/dist/index.js +164 -159
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +9 -2
- 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 +21 -13
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +1 -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 +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/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +18 -18
- 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 +159 -102
- package/src/state/types.ts +29 -12
- 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/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 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- 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 -13
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- 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/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/workspaceOps.ts +0 -102
|
@@ -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,18 +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
|
-
|
|
25
|
-
} from './
|
|
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,13 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { Instrument } from '../instrument/types';
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
5
|
-
addWorkspace,
|
|
6
|
-
cloneWorkspace,
|
|
7
|
-
closeWorkspace,
|
|
8
|
-
reorderWorkspaces,
|
|
9
|
-
resetWorkspace,
|
|
10
|
-
} from './workspaceOps';
|
|
3
|
+
import type { TrialRecord } from '../state/types';
|
|
4
|
+
import { addTrial, cloneTrial, closeTrial, reorderTrials, resetTrial } from './trialOps';
|
|
11
5
|
|
|
12
6
|
interface CounterState {
|
|
13
7
|
count: number;
|
|
@@ -40,9 +34,9 @@ function head<T>(arr: T[]): T {
|
|
|
40
34
|
return first;
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
describe('
|
|
37
|
+
describe('addTrial', () => {
|
|
44
38
|
it('appends a new record using defaultConfig and initialState', () => {
|
|
45
|
-
const out =
|
|
39
|
+
const out = addTrial([], instruments, 'Counter');
|
|
46
40
|
expect(out).toHaveLength(1);
|
|
47
41
|
expect(out[0]?.instrumentName).toBe('Counter');
|
|
48
42
|
expect(out[0]?.config).toEqual({ step: 1 });
|
|
@@ -50,38 +44,38 @@ describe('addWorkspace', () => {
|
|
|
50
44
|
});
|
|
51
45
|
|
|
52
46
|
it('assigns a unique id', () => {
|
|
53
|
-
const a =
|
|
54
|
-
const b =
|
|
47
|
+
const a = addTrial([], instruments, 'Counter');
|
|
48
|
+
const b = addTrial(a, instruments, 'Counter');
|
|
55
49
|
expect(b).toHaveLength(2);
|
|
56
50
|
expect(b[0]?.id).not.toBe(b[1]?.id);
|
|
57
51
|
});
|
|
58
52
|
|
|
59
53
|
it('uses canvas.initialView when present', () => {
|
|
60
|
-
const out =
|
|
54
|
+
const out = addTrial([], instruments, 'CounterCanvas');
|
|
61
55
|
expect(out[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
|
|
62
56
|
});
|
|
63
57
|
|
|
64
58
|
it('defaults view when canvas absent', () => {
|
|
65
|
-
const out =
|
|
59
|
+
const out = addTrial([], instruments, 'Counter');
|
|
66
60
|
expect(out[0]?.view).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
67
61
|
});
|
|
68
62
|
|
|
69
63
|
it('throws when instrumentName is unknown', () => {
|
|
70
|
-
expect(() =>
|
|
64
|
+
expect(() => addTrial([], instruments, 'Missing')).toThrow();
|
|
71
65
|
});
|
|
72
66
|
|
|
73
67
|
it('does not mutate input array', () => {
|
|
74
|
-
const arr:
|
|
75
|
-
|
|
68
|
+
const arr: TrialRecord[] = [];
|
|
69
|
+
addTrial(arr, instruments, 'Counter');
|
|
76
70
|
expect(arr).toHaveLength(0);
|
|
77
71
|
});
|
|
78
72
|
});
|
|
79
73
|
|
|
80
|
-
describe('
|
|
74
|
+
describe('cloneTrial', () => {
|
|
81
75
|
it('inserts clone immediately after source with new id', () => {
|
|
82
|
-
const a =
|
|
76
|
+
const a = addTrial([], instruments, 'Counter');
|
|
83
77
|
const sourceId = head(a).id;
|
|
84
|
-
const cloned =
|
|
78
|
+
const cloned = cloneTrial(a, sourceId);
|
|
85
79
|
expect(cloned).toHaveLength(2);
|
|
86
80
|
expect(cloned[0]?.id).toBe(sourceId);
|
|
87
81
|
expect(cloned[1]?.id).not.toBe(sourceId);
|
|
@@ -89,65 +83,65 @@ describe('cloneWorkspace', () => {
|
|
|
89
83
|
});
|
|
90
84
|
|
|
91
85
|
it('deep-copies config/state/view', () => {
|
|
92
|
-
const a =
|
|
93
|
-
const cloned =
|
|
86
|
+
const a = addTrial([], instruments, 'Counter');
|
|
87
|
+
const cloned = cloneTrial(a, head(a).id);
|
|
94
88
|
expect(cloned[1]?.config).not.toBe(cloned[0]?.config);
|
|
95
89
|
expect(cloned[1]?.state).not.toBe(cloned[0]?.state);
|
|
96
90
|
expect(cloned[1]?.view).not.toBe(cloned[0]?.view);
|
|
97
91
|
});
|
|
98
92
|
|
|
99
93
|
it('does not mutate input', () => {
|
|
100
|
-
const a =
|
|
94
|
+
const a = addTrial([], instruments, 'Counter');
|
|
101
95
|
const before = a.length;
|
|
102
|
-
|
|
96
|
+
cloneTrial(a, head(a).id);
|
|
103
97
|
expect(a).toHaveLength(before);
|
|
104
98
|
});
|
|
105
99
|
});
|
|
106
100
|
|
|
107
|
-
describe('
|
|
108
|
-
it('removes the
|
|
109
|
-
let arr =
|
|
110
|
-
arr =
|
|
111
|
-
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);
|
|
112
106
|
expect(closed).toHaveLength(1);
|
|
113
107
|
expect(closed[0]?.id).toBe(arr[1]?.id);
|
|
114
108
|
});
|
|
115
109
|
|
|
116
|
-
it('is a no-op when only one
|
|
117
|
-
const arr =
|
|
118
|
-
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);
|
|
119
113
|
expect(closed).toEqual(arr);
|
|
120
114
|
});
|
|
121
115
|
});
|
|
122
116
|
|
|
123
|
-
describe('
|
|
117
|
+
describe('resetTrial', () => {
|
|
124
118
|
it('resets config and state to defaults; preserves instrumentName', () => {
|
|
125
|
-
const arr =
|
|
119
|
+
const arr = addTrial([], instruments, 'Counter');
|
|
126
120
|
const id = head(arr).id;
|
|
127
121
|
head(arr).config = { step: 99 } as CounterConfig;
|
|
128
122
|
head(arr).state = { count: 42 } as CounterState;
|
|
129
|
-
const reset =
|
|
123
|
+
const reset = resetTrial(arr, id, instruments);
|
|
130
124
|
expect(reset[0]?.config).toEqual({ step: 1 });
|
|
131
125
|
expect(reset[0]?.state).toEqual({ count: 0 });
|
|
132
126
|
expect(reset[0]?.instrumentName).toBe('Counter');
|
|
133
127
|
});
|
|
134
128
|
|
|
135
129
|
it('resets view to canvas.initialView when present', () => {
|
|
136
|
-
const arr =
|
|
130
|
+
const arr = addTrial([], instruments, 'CounterCanvas');
|
|
137
131
|
head(arr).view = { zoom: 9, pan: { x: 9, y: 9 } };
|
|
138
|
-
const reset =
|
|
132
|
+
const reset = resetTrial(arr, head(arr).id, instruments);
|
|
139
133
|
expect(reset[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
|
|
140
134
|
});
|
|
141
135
|
|
|
142
136
|
it('does not mutate input', () => {
|
|
143
|
-
const arr =
|
|
137
|
+
const arr = addTrial([], instruments, 'Counter');
|
|
144
138
|
head(arr).config = { step: 99 } as CounterConfig;
|
|
145
|
-
|
|
139
|
+
resetTrial(arr, head(arr).id, instruments);
|
|
146
140
|
expect((arr[0]?.config as CounterConfig).step).toBe(99);
|
|
147
141
|
});
|
|
148
142
|
});
|
|
149
143
|
|
|
150
|
-
describe('
|
|
144
|
+
describe('reorderTrials', () => {
|
|
151
145
|
const ws = (id: string) =>
|
|
152
146
|
({
|
|
153
147
|
id,
|
|
@@ -159,17 +153,17 @@ describe('reorderWorkspaces', () => {
|
|
|
159
153
|
}) as never;
|
|
160
154
|
|
|
161
155
|
it('reorders to match the given ids', () => {
|
|
162
|
-
const next =
|
|
156
|
+
const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
|
|
163
157
|
expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
|
|
164
158
|
});
|
|
165
159
|
|
|
166
160
|
it('drops ids that no longer exist rather than resurrecting them', () => {
|
|
167
|
-
const next =
|
|
161
|
+
const next = reorderTrials([ws('a'), ws('b')], ['gone', 'b', 'a']);
|
|
168
162
|
expect(next.map((w) => w.id)).toEqual(['b', 'a']);
|
|
169
163
|
});
|
|
170
164
|
|
|
171
|
-
it('keeps unmentioned
|
|
172
|
-
const next =
|
|
165
|
+
it('keeps unmentioned trials, after the named ones', () => {
|
|
166
|
+
const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c']);
|
|
173
167
|
expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
|
|
174
168
|
});
|
|
175
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) => {
|