@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createMemoryAdapter } from './adapters';
|
|
3
|
+
import { createLabStore } from './store';
|
|
4
|
+
import { as2DView, DEFAULT_VIEW } from './view';
|
|
5
|
+
|
|
6
|
+
interface OrbitView {
|
|
7
|
+
yaw: number;
|
|
8
|
+
pitch: number;
|
|
9
|
+
distance: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const orbit: OrbitView = { yaw: 1.1, pitch: 0.3, distance: 9 };
|
|
13
|
+
|
|
14
|
+
describe('as2DView', () => {
|
|
15
|
+
it('accepts the 2D shape', () => {
|
|
16
|
+
expect(as2DView({ zoom: 2, pan: { x: 1, y: 3 } })).toEqual({ zoom: 2, pan: { x: 1, y: 3 } });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('copies rather than aliasing, so a caller cannot mutate the record', () => {
|
|
20
|
+
const source = { zoom: 2, pan: { x: 1, y: 3 } };
|
|
21
|
+
const out = as2DView(source);
|
|
22
|
+
expect(out).not.toBe(source);
|
|
23
|
+
expect(out?.pan).not.toBe(source.pan);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects an orbit view', () => {
|
|
27
|
+
expect(as2DView(orbit)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects a partial 2D view rather than filling in a default', () => {
|
|
31
|
+
expect(as2DView({ zoom: 2 })).toBeNull();
|
|
32
|
+
expect(as2DView({ zoom: 2, pan: { x: 1 } })).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects things that are not objects', () => {
|
|
36
|
+
expect(as2DView(null)).toBeNull();
|
|
37
|
+
expect(as2DView(undefined)).toBeNull();
|
|
38
|
+
expect(as2DView(4)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('a trial view labkit does not interpret', () => {
|
|
43
|
+
it('stores and returns a view shape that is not zoom/pan', () => {
|
|
44
|
+
const store = createLabStore({ storageKey: 'view-a', storage: createMemoryAdapter() });
|
|
45
|
+
store.getState().addTrial({
|
|
46
|
+
id: 'w1',
|
|
47
|
+
instrumentName: 'gem',
|
|
48
|
+
config: {},
|
|
49
|
+
state: {},
|
|
50
|
+
view: DEFAULT_VIEW,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
store.getState().updateTrialView('w1', orbit);
|
|
54
|
+
|
|
55
|
+
expect(store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('round-trips that view through persistence', () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
const mem = createMemoryAdapter();
|
|
61
|
+
const seed = createLabStore({ storageKey: 'view-b', storage: mem });
|
|
62
|
+
seed.getState().addTrial({
|
|
63
|
+
id: 'w1',
|
|
64
|
+
instrumentName: 'gem',
|
|
65
|
+
config: {},
|
|
66
|
+
state: {},
|
|
67
|
+
view: orbit,
|
|
68
|
+
});
|
|
69
|
+
vi.advanceTimersByTime(500);
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
|
|
72
|
+
const hydrated = createLabStore({ storageKey: 'view-b', storage: mem });
|
|
73
|
+
expect(hydrated.getState().trials[0]?.view as OrbitView).toEqual(orbit);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('an unchanged write costs no new record', () => {
|
|
78
|
+
it('returns the same trial object when setState returns its input', () => {
|
|
79
|
+
const store = createLabStore({ storageKey: 'identity-a', storage: createMemoryAdapter() });
|
|
80
|
+
store.getState().addTrial({
|
|
81
|
+
id: 'w1',
|
|
82
|
+
instrumentName: 'gem',
|
|
83
|
+
config: {},
|
|
84
|
+
state: { n: 1 },
|
|
85
|
+
view: DEFAULT_VIEW,
|
|
86
|
+
});
|
|
87
|
+
const before = store.getState().trials[0];
|
|
88
|
+
|
|
89
|
+
store.getState().updateTrialState('w1', (prev: unknown) => prev);
|
|
90
|
+
|
|
91
|
+
// A trial re-renders on record identity, so allocating here would turn the
|
|
92
|
+
// standard React bail-out into a render loop.
|
|
93
|
+
expect(store.getState().trials[0]).toBe(before);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('still replaces the record when the state actually changes', () => {
|
|
97
|
+
const store = createLabStore({ storageKey: 'identity-b', storage: createMemoryAdapter() });
|
|
98
|
+
store.getState().addTrial({
|
|
99
|
+
id: 'w1',
|
|
100
|
+
instrumentName: 'gem',
|
|
101
|
+
config: {},
|
|
102
|
+
state: { n: 1 },
|
|
103
|
+
view: DEFAULT_VIEW,
|
|
104
|
+
});
|
|
105
|
+
const before = store.getState().trials[0];
|
|
106
|
+
|
|
107
|
+
store.getState().updateTrialState('w1', { n: 2 });
|
|
108
|
+
|
|
109
|
+
expect(store.getState().trials[0]).not.toBe(before);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns the same trial object when the view is written unchanged', () => {
|
|
113
|
+
const store = createLabStore({ storageKey: 'identity-c', storage: createMemoryAdapter() });
|
|
114
|
+
store.getState().addTrial({
|
|
115
|
+
id: 'w1',
|
|
116
|
+
instrumentName: 'gem',
|
|
117
|
+
config: {},
|
|
118
|
+
state: {},
|
|
119
|
+
view: orbit,
|
|
120
|
+
});
|
|
121
|
+
const before = store.getState().trials[0];
|
|
122
|
+
|
|
123
|
+
store.getState().updateTrialView('w1', orbit);
|
|
124
|
+
|
|
125
|
+
expect(store.getState().trials[0]).toBe(before);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ViewTransform } from '../instrument/types';
|
|
2
|
+
|
|
3
|
+
/** The 2D view labkit has always shipped, and what a trial gets when it names no
|
|
4
|
+
* other. Re-exported under its own name so a consumer can say which it means. */
|
|
5
|
+
export type ViewTransform2D = ViewTransform;
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_VIEW: ViewTransform2D = { zoom: 1, pan: { x: 0, y: 0 } };
|
|
8
|
+
|
|
9
|
+
/** A trial's view is opaque to labkit, so anything that needs the 2D shape — the
|
|
10
|
+
* zoom chrome, `CanvasStack` — asks for it and handles not getting it. */
|
|
11
|
+
export function as2DView(view: unknown): ViewTransform2D | null {
|
|
12
|
+
if (typeof view !== 'object' || view === null) return null;
|
|
13
|
+
const v = view as Partial<ViewTransform2D>;
|
|
14
|
+
if (typeof v.zoom !== 'number') return null;
|
|
15
|
+
if (typeof v.pan !== 'object' || v.pan === null) return null;
|
|
16
|
+
if (typeof v.pan.x !== 'number' || typeof v.pan.y !== 'number') return null;
|
|
17
|
+
return { zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } };
|
|
18
|
+
}
|
package/src/styles.less
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Imports compile-time only. Component .less files are added in later steps.
|
|
2
2
|
@import './theme/base.less';
|
|
3
|
-
@import './lab/
|
|
3
|
+
@import './lab/Workspace.less';
|
|
4
4
|
@import './lab/LabShell.less';
|
|
5
5
|
@import './primitives/Toolbar.less';
|
|
6
6
|
@import './primitives/Sidebar.less';
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
@import './primitives/FpsMeter.less';
|
|
9
9
|
@import './primitives/ScaleIndicator.less';
|
|
10
10
|
@import './lab/Lab.less';
|
|
11
|
-
@import './
|
|
11
|
+
@import './trial/Trial.less';
|
|
12
12
|
@import './controls/ControlPanel.less';
|
|
13
13
|
@import './canvas/CanvasStack.less';
|
|
14
14
|
@import './layers/LayerList.less';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Surface — Agent Guide
|
|
2
|
+
|
|
3
|
+
`src/surface/` lets a lab drive a renderer labkit does not own. labkit publishes
|
|
4
|
+
rects, dirtiness, DPR and one rAF; the consumer keeps the GL.
|
|
5
|
+
|
|
6
|
+
Use this rather than the `canvas` capability when the renderer is not labkit's —
|
|
7
|
+
three.js, a raw WebGL context, anything with its own render loop. One canvas per
|
|
8
|
+
trial is one WebGL context per trial, and browsers cap those around 8–16, so more
|
|
9
|
+
than a few 3D tiles have to share one surface.
|
|
10
|
+
|
|
11
|
+
## Files
|
|
12
|
+
|
|
13
|
+
| File | Role |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `rect.ts` | The `Rect` and `Box` types |
|
|
16
|
+
| `composeRects.ts` | Tile boxes into surface-relative rects; both are viewport-relative, so it is a subtraction |
|
|
17
|
+
| `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
|
|
18
|
+
| `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
|
|
19
|
+
| `SurfaceContext.ts` | Carries the handle down |
|
|
20
|
+
| `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useSurface()`, `useSurfaceOptional()` |
|
|
21
|
+
|
|
22
|
+
## Shape of a consumer
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
const surface = useTiledSurface({
|
|
26
|
+
onFrame: ({ dirty, rects, dpr, size }) => {
|
|
27
|
+
renderer.setPixelRatio(Math.min(dpr, 2));
|
|
28
|
+
renderer.setSize(size.width, size.height, false);
|
|
29
|
+
for (const id of dirty) {
|
|
30
|
+
const rect = rects.get(id);
|
|
31
|
+
if (!rect) continue;
|
|
32
|
+
const v = toDeviceRect(rect, size.height, dpr);
|
|
33
|
+
// ... setViewport / setScissor / render
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`onFrame` carries **every** tile's rect, not only the dirty ones — a scissored
|
|
40
|
+
draw has to know where it is drawing relative to a surface that may have resized
|
|
41
|
+
under it.
|
|
42
|
+
|
|
43
|
+
## The unit is a rect, not a trial
|
|
44
|
+
|
|
45
|
+
`useSurfaceTile(id)` attaches to whatever element the surface should draw into. A
|
|
46
|
+
trial may register one, or none: a trial holding a drawn pane beside an undrawn
|
|
47
|
+
one contributes a single rect, and a trial with nothing to draw contributes none.
|
|
48
|
+
|
|
49
|
+
## Traps
|
|
50
|
+
|
|
51
|
+
- **`preserveDrawingBuffer` is the consumer's job and is usually required.** A
|
|
52
|
+
partial redraw touches one tile; without it the default framebuffer's contents
|
|
53
|
+
are undefined after the page composites, and every other tile goes black.
|
|
54
|
+
- **Gutters lie outside every scissor.** Clear the whole surface when the tile set
|
|
55
|
+
changes, or a re-tile strands the old tiles' pixels between the new ones.
|
|
56
|
+
- **A tile that moves without resizing** is already handled: `Workspace`
|
|
57
|
+
invalidates rects off the grid's own `node.placementChanged`. A host that moves
|
|
58
|
+
something the grid does not know about calls `invalidateRects()` itself.
|
|
59
|
+
|
|
60
|
+
## Testing
|
|
61
|
+
|
|
62
|
+
No WebGL in the suite. The arithmetic is pure and tested directly; the hook is
|
|
63
|
+
tested with `getBoundingClientRect` stubbed per element, because jsdom measures
|
|
64
|
+
everything as zero.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { composeRects, rectsEqual } from './composeRects';
|
|
3
|
+
import type { Box } from './rect';
|
|
4
|
+
|
|
5
|
+
const box = (left: number, top: number, width: number, height: number): Box => ({
|
|
6
|
+
left,
|
|
7
|
+
top,
|
|
8
|
+
width,
|
|
9
|
+
height,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('composeRects', () => {
|
|
13
|
+
it('reports tiles relative to the container origin', () => {
|
|
14
|
+
const out = composeRects(box(100, 50, 800, 600), new Map([['a', box(140, 90, 200, 150)]]));
|
|
15
|
+
expect(out.get('a')).toEqual({ x: 40, y: 40, w: 200, h: 150 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('works through a nested offset parent, because both are viewport-relative', () => {
|
|
19
|
+
const out = composeRects(box(0, 0, 800, 600), new Map([['deep', box(310, 220, 90, 40)]]));
|
|
20
|
+
expect(out.get('deep')).toEqual({ x: 310, y: 220, w: 90, h: 40 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('handles a container scrolled off the top of the viewport', () => {
|
|
24
|
+
const out = composeRects(box(0, -200, 800, 600), new Map([['a', box(0, -150, 100, 100)]]));
|
|
25
|
+
expect(out.get('a')).toEqual({ x: 0, y: 50, w: 100, h: 100 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns one entry per tile', () => {
|
|
29
|
+
const out = composeRects(
|
|
30
|
+
box(0, 0, 800, 600),
|
|
31
|
+
new Map([
|
|
32
|
+
['a', box(0, 0, 10, 10)],
|
|
33
|
+
['b', box(20, 0, 10, 10)],
|
|
34
|
+
]),
|
|
35
|
+
);
|
|
36
|
+
expect([...out.keys()]).toEqual(['a', 'b']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('rectsEqual', () => {
|
|
41
|
+
it('is false when the previous rect is missing', () => {
|
|
42
|
+
expect(rectsEqual(undefined, { x: 0, y: 0, w: 1, h: 1 })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('compares every field', () => {
|
|
46
|
+
const r = { x: 1, y: 2, w: 3, h: 4 };
|
|
47
|
+
expect(rectsEqual({ ...r }, r)).toBe(true);
|
|
48
|
+
expect(rectsEqual({ ...r, h: 5 }, r)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Box, Rect } from './rect';
|
|
2
|
+
|
|
3
|
+
/** Every tile's box expressed against the container's origin. */
|
|
4
|
+
export function composeRects(container: Box, tiles: ReadonlyMap<string, Box>): Map<string, Rect> {
|
|
5
|
+
const out = new Map<string, Rect>();
|
|
6
|
+
for (const [id, tile] of tiles) {
|
|
7
|
+
out.set(id, {
|
|
8
|
+
x: tile.left - container.left,
|
|
9
|
+
y: tile.top - container.top,
|
|
10
|
+
w: tile.width,
|
|
11
|
+
h: tile.height,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function rectsEqual(a: Rect | undefined, b: Rect): boolean {
|
|
18
|
+
return a !== undefined && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { toDeviceRect } from './deviceRect';
|
|
3
|
+
|
|
4
|
+
const onGrid = (v: number, dpr: number) => Math.abs(v * dpr - Math.round(v * dpr)) < 1e-9;
|
|
5
|
+
|
|
6
|
+
describe('toDeviceRect', () => {
|
|
7
|
+
it('measures y from the bottom of the surface', () => {
|
|
8
|
+
const r = toDeviceRect({ x: 0, y: 0, w: 40, h: 10 }, 100, 1);
|
|
9
|
+
expect(r.y).toBe(90);
|
|
10
|
+
expect(r.h).toBe(10);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('leaves x and width alone', () => {
|
|
14
|
+
const r = toDeviceRect({ x: 5, y: 0, w: 40, h: 10 }, 100, 1);
|
|
15
|
+
expect(r.x).toBe(5);
|
|
16
|
+
expect(r.w).toBe(40);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('puts every edge on the device grid', () => {
|
|
20
|
+
const r = toDeviceRect({ x: 33.3, y: 7.7, w: 33.3, h: 21.4 }, 100, 2);
|
|
21
|
+
expect(onGrid(r.x, 2)).toBe(true);
|
|
22
|
+
expect(onGrid(r.x + r.w, 2)).toBe(true);
|
|
23
|
+
expect(onGrid(r.y, 2)).toBe(true);
|
|
24
|
+
expect(onGrid(r.y + r.h, 2)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('does not strand a column between neighbours', () => {
|
|
28
|
+
const a = toDeviceRect({ x: 0, y: 0, w: 33.3333, h: 10 }, 100, 2);
|
|
29
|
+
const b = toDeviceRect({ x: 33.3333, y: 0, w: 33.3333, h: 10 }, 100, 2);
|
|
30
|
+
const c = toDeviceRect({ x: 66.6666, y: 0, w: 33.3334, h: 10 }, 100, 2);
|
|
31
|
+
expect(a.x + a.w).toBe(b.x);
|
|
32
|
+
expect(b.x + b.w).toBe(c.x);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('snaps a stacked pair without overlapping them', () => {
|
|
36
|
+
const top = toDeviceRect({ x: 0, y: 0, w: 10, h: 33.3333 }, 100, 3);
|
|
37
|
+
const bottom = toDeviceRect({ x: 0, y: 33.3333, w: 10, h: 33.3333 }, 100, 3);
|
|
38
|
+
expect(bottom.y + bottom.h).toBe(top.y);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Rect } from './rect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A DOM rect as a GL viewport rect: origin at the bottom-left, every edge snapped
|
|
5
|
+
* to the device-pixel grid. Still CSS pixels, because three.js applies its own
|
|
6
|
+
* pixel ratio — snapping here is what stops a tile and its neighbour rounding
|
|
7
|
+
* apart and leaving a hairline column between them.
|
|
8
|
+
*/
|
|
9
|
+
export function toDeviceRect(rect: Rect, surfaceHeight: number, dpr: number): Rect {
|
|
10
|
+
const snap = (v: number) => Math.round(v * dpr) / dpr;
|
|
11
|
+
const x = snap(rect.x);
|
|
12
|
+
const y = snap(surfaceHeight - rect.y - rect.h);
|
|
13
|
+
return {
|
|
14
|
+
x,
|
|
15
|
+
y,
|
|
16
|
+
w: snap(rect.x + rect.w) - x,
|
|
17
|
+
h: snap(surfaceHeight - rect.y) - y,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { composeRects, rectsEqual } from './composeRects';
|
|
2
|
+
export { toDeviceRect } from './deviceRect';
|
|
3
|
+
export type { Box, Rect } from './rect';
|
|
4
|
+
export { SurfaceContext } from './SurfaceContext';
|
|
5
|
+
export { useSurface, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
|
|
6
|
+
export type { SurfaceFrame, SurfaceHandle, UseTiledSurfaceOptions } from './useTiledSurface';
|
|
7
|
+
export { useTiledSurface } from './useTiledSurface';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A box in CSS pixels, measured from the surface's own top-left corner. */
|
|
2
|
+
export interface Rect {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
w: number;
|
|
6
|
+
h: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The subset of `DOMRect` this package reads. Accepting the subset rather than
|
|
10
|
+
* `DOMRect` is what lets the pure functions be tested without a DOM. */
|
|
11
|
+
export interface Box {
|
|
12
|
+
left: number;
|
|
13
|
+
top: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { SurfaceContext } from './SurfaceContext';
|
|
4
|
+
import { useSurface, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
|
|
5
|
+
import type { SurfaceHandle } from './useTiledSurface';
|
|
6
|
+
|
|
7
|
+
function fakeHandle(): SurfaceHandle {
|
|
8
|
+
return {
|
|
9
|
+
invalidate: vi.fn(),
|
|
10
|
+
invalidateAll: vi.fn(),
|
|
11
|
+
invalidateRects: vi.fn(),
|
|
12
|
+
registerTile: vi.fn(),
|
|
13
|
+
containerRef: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function Tile({ id }: { id: string }) {
|
|
18
|
+
const ref = useSurfaceTile(id);
|
|
19
|
+
return <div ref={ref} data-testid={id} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('useSurfaceTile', () => {
|
|
23
|
+
it('registers its element with the surface on mount', () => {
|
|
24
|
+
const handle = fakeHandle();
|
|
25
|
+
const { getByTestId } = render(
|
|
26
|
+
<SurfaceContext.Provider value={handle}>
|
|
27
|
+
<Tile id="a" />
|
|
28
|
+
</SurfaceContext.Provider>,
|
|
29
|
+
);
|
|
30
|
+
expect(handle.registerTile).toHaveBeenCalledWith('a', getByTestId('a'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('unregisters on unmount', () => {
|
|
34
|
+
const handle = fakeHandle();
|
|
35
|
+
const { unmount } = render(
|
|
36
|
+
<SurfaceContext.Provider value={handle}>
|
|
37
|
+
<Tile id="a" />
|
|
38
|
+
</SurfaceContext.Provider>,
|
|
39
|
+
);
|
|
40
|
+
unmount();
|
|
41
|
+
expect(handle.registerTile).toHaveBeenCalledWith('a', null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('is inert with no surface above it, so a 2D lab is unaffected', () => {
|
|
45
|
+
expect(() => render(<Tile id="a" />)).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('useSurface', () => {
|
|
50
|
+
it('throws outside a provider, because a caller asking for it needs one', () => {
|
|
51
|
+
function Consumer() {
|
|
52
|
+
useSurface();
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
expect(() => render(<Consumer />)).toThrow(/requires a surface/i);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns null from the optional form outside a provider', () => {
|
|
59
|
+
let seen: SurfaceHandle | null | undefined;
|
|
60
|
+
function Consumer() {
|
|
61
|
+
seen = useSurfaceOptional();
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
render(<Consumer />);
|
|
65
|
+
expect(seen).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useCallback, useContext } from 'react';
|
|
2
|
+
import { SurfaceContext } from './SurfaceContext';
|
|
3
|
+
import type { SurfaceHandle } from './useTiledSurface';
|
|
4
|
+
|
|
5
|
+
/** The surface above, or null. Use this where a surface is genuinely optional. */
|
|
6
|
+
export function useSurfaceOptional(): SurfaceHandle | null {
|
|
7
|
+
return useContext(SurfaceContext);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The surface above. Throws where a caller cannot work without one. */
|
|
11
|
+
export function useSurface(): SurfaceHandle {
|
|
12
|
+
const surface = useContext(SurfaceContext);
|
|
13
|
+
if (!surface) throw new Error('[labkit] useSurface requires a surface owner above it');
|
|
14
|
+
return surface;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A ref callback that publishes this element's rect to the surface under `id`.
|
|
19
|
+
*
|
|
20
|
+
* Attach it to whichever element the surface should draw into — that is not
|
|
21
|
+
* necessarily the trial's own element, since a trial may hold a drawn pane beside
|
|
22
|
+
* an undrawn one, or none at all.
|
|
23
|
+
*/
|
|
24
|
+
export function useSurfaceTile(id: string): (el: HTMLElement | null) => void {
|
|
25
|
+
const surface = useSurfaceOptional();
|
|
26
|
+
return useCallback(
|
|
27
|
+
(el: HTMLElement | null) => {
|
|
28
|
+
surface?.registerTile(id, el);
|
|
29
|
+
},
|
|
30
|
+
[surface, id],
|
|
31
|
+
);
|
|
32
|
+
}
|