@weasel-js/labkit 1.1.0 → 1.3.0-pre.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 +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- 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 +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- 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.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- 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 +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { NumberField, Slider } from '../passthrough/weasel-ui';
|
|
2
|
+
|
|
3
|
+
/** Props for `<ZoomControl>`. */
|
|
4
|
+
export interface ZoomControlProps {
|
|
5
|
+
/** Current zoom as a scale factor: 1 is actual size. */
|
|
6
|
+
zoom: number;
|
|
7
|
+
/** Fired continuously while the slider drags. */
|
|
8
|
+
onZoomChange: (zoom: number) => void;
|
|
9
|
+
/** Fired when a drag settles or the field is committed. Defaults to
|
|
10
|
+
* `onZoomChange`. */
|
|
11
|
+
onZoomCommit?: (zoom: number) => void;
|
|
12
|
+
/** Smallest selectable zoom. Defaults to 0.1 (10%). */
|
|
13
|
+
min?: number;
|
|
14
|
+
/** Largest selectable zoom. Defaults to 8 (800%). */
|
|
15
|
+
max?: number;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Zoom is perceptually geometric — halving is as big a step as doubling — so
|
|
20
|
+
// the slider runs on log2 and 100% lands mid-track for a symmetric range.
|
|
21
|
+
const toTrack = (zoom: number): number => Math.log2(zoom);
|
|
22
|
+
const fromTrack = (v: number): number => 2 ** v;
|
|
23
|
+
|
|
24
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
|
|
25
|
+
|
|
26
|
+
/** Zoom as a log-scaled slider with an editable percentage beside it. Pair it
|
|
27
|
+
* with zoom-in / zoom-out buttons in a `<Toolbar.Group>`; this component owns
|
|
28
|
+
* only the continuous part. */
|
|
29
|
+
export function ZoomControl({
|
|
30
|
+
zoom,
|
|
31
|
+
onZoomChange,
|
|
32
|
+
onZoomCommit,
|
|
33
|
+
min = 0.1,
|
|
34
|
+
max = 8,
|
|
35
|
+
className,
|
|
36
|
+
}: ZoomControlProps) {
|
|
37
|
+
const commit = onZoomCommit ?? onZoomChange;
|
|
38
|
+
const emit = (track: number, done: boolean): void => {
|
|
39
|
+
const next = clamp(fromTrack(track), min, max);
|
|
40
|
+
(done ? commit : onZoomChange)(next);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={`lk-zoom${className ? ` ${className}` : ''}`}>
|
|
45
|
+
<Slider
|
|
46
|
+
className="lk-zoom__slider"
|
|
47
|
+
ariaLabel="Zoom"
|
|
48
|
+
min={toTrack(min)}
|
|
49
|
+
max={toTrack(max)}
|
|
50
|
+
// Detents at the octaves plus actual size, so 25/50/100/200/400% are
|
|
51
|
+
// reachable by drag rather than only by typing.
|
|
52
|
+
stops={[-2, -1, 0, 1, 2]}
|
|
53
|
+
thumbs={[{ value: clamp(toTrack(zoom), toTrack(min), toTrack(max)) }]}
|
|
54
|
+
onInput={(next) => emit(next[0].value, false)}
|
|
55
|
+
onChange={(next) => emit(next[0].value, true)}
|
|
56
|
+
readoutPlacement="none"
|
|
57
|
+
/>
|
|
58
|
+
{/* `style: 'percent'` formats a fraction, so the field carries the scale
|
|
59
|
+
factor itself and renders 0.5 as "50%". */}
|
|
60
|
+
<NumberField
|
|
61
|
+
className="lk-zoom__field"
|
|
62
|
+
aria-label="Zoom"
|
|
63
|
+
value={zoom}
|
|
64
|
+
minValue={min}
|
|
65
|
+
maxValue={max}
|
|
66
|
+
step={0.01}
|
|
67
|
+
hideSteppers
|
|
68
|
+
formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
|
|
69
|
+
onChange={(next) => {
|
|
70
|
+
if (!Number.isFinite(next)) return;
|
|
71
|
+
commit(clamp(next, min, max));
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { FloatingPanel, Legend } from '../index';
|
|
4
|
+
|
|
5
|
+
describe('floating public entry', () => {
|
|
6
|
+
it('reaches a consumer from the package entry point alone', () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<FloatingPanel>
|
|
9
|
+
<Legend entries={[{ key: 'a', label: 'a', color: '#fff' }]} />
|
|
10
|
+
</FloatingPanel>,
|
|
11
|
+
);
|
|
12
|
+
expect(container.querySelector('.lk-floating-panel .lk-legend')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
});
|
package/src/primitives/index.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
export type { FloatingPanelProps } from './FloatingPanel';
|
|
2
|
+
export { FloatingPanel } from './FloatingPanel';
|
|
1
3
|
export { FpsMeter } from './FpsMeter';
|
|
4
|
+
export type { JobProgressProps } from './JobProgress';
|
|
5
|
+
export { JobProgress } from './JobProgress';
|
|
6
|
+
export type { LegendEntry, LegendMark, LegendProps } from './Legend';
|
|
7
|
+
export { Legend } from './Legend';
|
|
2
8
|
export type { ScaleIndicatorProps } from './ScaleIndicator';
|
|
3
9
|
export { ScaleIndicator } from './ScaleIndicator';
|
|
4
10
|
export type { SidebarProps } from './Sidebar';
|
|
5
11
|
export { Sidebar } from './Sidebar';
|
|
6
|
-
export type { StatusBarProps } from './StatusBar';
|
|
12
|
+
export type { StatusBarProps, StatusBarSectionProps } from './StatusBar';
|
|
7
13
|
export { StatusBar } from './StatusBar';
|
|
8
|
-
export type { ToolbarProps } from './Toolbar';
|
|
14
|
+
export type { ToolbarButtonProps, ToolbarGroupProps, ToolbarProps } from './Toolbar';
|
|
9
15
|
export { Toolbar } from './Toolbar';
|
|
16
|
+
export type { ZoomControlProps } from './ZoomControl';
|
|
17
|
+
export { ZoomControl } from './ZoomControl';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { nextIndex } from './useRovingTabIndex';
|
|
3
|
+
|
|
4
|
+
describe('nextIndex', () => {
|
|
5
|
+
it('steps forward and wraps', () => {
|
|
6
|
+
expect(nextIndex(0, 'ArrowRight', 3)).toBe(1);
|
|
7
|
+
expect(nextIndex(2, 'ArrowRight', 3)).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('steps back and wraps', () => {
|
|
11
|
+
expect(nextIndex(1, 'ArrowLeft', 3)).toBe(0);
|
|
12
|
+
expect(nextIndex(0, 'ArrowLeft', 3)).toBe(2);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('jumps to the ends', () => {
|
|
16
|
+
expect(nextIndex(1, 'Home', 3)).toBe(0);
|
|
17
|
+
expect(nextIndex(1, 'End', 3)).toBe(2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns null for a key it does not handle', () => {
|
|
21
|
+
expect(nextIndex(1, 'Enter', 3)).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns null when there is nothing to move to', () => {
|
|
25
|
+
expect(nextIndex(0, 'ArrowRight', 0)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Where a key takes focus within `count` items, or null if it does not move it. */
|
|
4
|
+
export function nextIndex(current: number, key: string, count: number): number | null {
|
|
5
|
+
if (count === 0) return null;
|
|
6
|
+
switch (key) {
|
|
7
|
+
case 'ArrowRight':
|
|
8
|
+
return (current + 1) % count;
|
|
9
|
+
case 'ArrowLeft':
|
|
10
|
+
return (current - 1 + count) % count;
|
|
11
|
+
case 'Home':
|
|
12
|
+
return 0;
|
|
13
|
+
case 'End':
|
|
14
|
+
return count - 1;
|
|
15
|
+
default:
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The APG roving-tabindex contract: exactly one item in the tab order, arrows
|
|
22
|
+
* moving focus within. `tabIndex` is written onto the DOM nodes rather than
|
|
23
|
+
* threaded through props, because the items are arbitrary children — the
|
|
24
|
+
* container never sees them as a list it could index.
|
|
25
|
+
*/
|
|
26
|
+
export function useRovingTabIndex<T extends HTMLElement>() {
|
|
27
|
+
const ref = useRef<T | null>(null);
|
|
28
|
+
const stop = useRef(0);
|
|
29
|
+
|
|
30
|
+
const items = useCallback(
|
|
31
|
+
(): HTMLElement[] =>
|
|
32
|
+
ref.current
|
|
33
|
+
? Array.from(
|
|
34
|
+
ref.current.querySelectorAll<HTMLElement>('button:not([disabled]), [role="button"]'),
|
|
35
|
+
)
|
|
36
|
+
: [],
|
|
37
|
+
[],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const setTabStop = useCallback(
|
|
41
|
+
(index: number) => {
|
|
42
|
+
const found = items();
|
|
43
|
+
const at = Math.min(Math.max(index, 0), Math.max(found.length - 1, 0));
|
|
44
|
+
stop.current = at;
|
|
45
|
+
for (const [i, el] of found.entries()) el.tabIndex = i === at ? 0 : -1;
|
|
46
|
+
},
|
|
47
|
+
[items],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Re-establish the tab stop whenever the item set changes — a trial's toolbar
|
|
51
|
+
// contributions are dynamic, and `disabled` moves a button in and out of the
|
|
52
|
+
// set without touching the child list. The focused item keeps the stop.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const resync = () => {
|
|
55
|
+
const focused = items().indexOf(document.activeElement as HTMLElement);
|
|
56
|
+
setTabStop(focused === -1 ? stop.current : focused);
|
|
57
|
+
};
|
|
58
|
+
resync();
|
|
59
|
+
if (!ref.current) return;
|
|
60
|
+
const mo = new MutationObserver(resync);
|
|
61
|
+
mo.observe(ref.current, { childList: true, subtree: true, attributeFilter: ['disabled'] });
|
|
62
|
+
return () => mo.disconnect();
|
|
63
|
+
}, [items, setTabStop]);
|
|
64
|
+
|
|
65
|
+
const onKeyDown = useCallback(
|
|
66
|
+
(e: KeyboardEvent<T>) => {
|
|
67
|
+
const found = items();
|
|
68
|
+
const current = found.indexOf(document.activeElement as HTMLElement);
|
|
69
|
+
const next = nextIndex(current === -1 ? 0 : current, e.key, found.length);
|
|
70
|
+
if (next === null) return;
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
found[next]?.focus();
|
|
73
|
+
setTabStop(next);
|
|
74
|
+
},
|
|
75
|
+
[items, setTabStop],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return { ref, onKeyDown };
|
|
79
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface LabStoreActions {
|
|
|
28
28
|
removeTrial: (id: string) => void;
|
|
29
29
|
updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
|
|
30
30
|
updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
|
|
31
|
-
updateTrialView: (id: string, view:
|
|
31
|
+
updateTrialView: (id: string, view: unknown) => void;
|
|
32
32
|
updateTrialUndoStack: (
|
|
33
33
|
id: string,
|
|
34
34
|
next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
|
|
@@ -39,6 +39,8 @@ export interface LabStoreActions {
|
|
|
39
39
|
deleteSnapshot: (snapshotId: string) => void;
|
|
40
40
|
listSnapshots: (trialId?: string) => SavedSnapshot[];
|
|
41
41
|
setMode: (mode: LabMode) => void;
|
|
42
|
+
setLabTool: (id: string | null) => void;
|
|
43
|
+
setTrialTool: (trialId: string, id: string | null) => void;
|
|
42
44
|
setLayout: (layout: Record<string, unknown>) => void;
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -70,6 +72,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
70
72
|
savedSnapshots: hydratedSnapshots,
|
|
71
73
|
mode: hydratedMode,
|
|
72
74
|
layout: hydratedLayout,
|
|
75
|
+
activeToolId: null,
|
|
73
76
|
|
|
74
77
|
addTrial: (record) => {
|
|
75
78
|
set((s) => ({
|
|
@@ -89,6 +92,10 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
89
92
|
if (w.id !== id) return w;
|
|
90
93
|
const nextState =
|
|
91
94
|
typeof next === 'function' ? (next as (prev: unknown) => unknown)(w.state) : next;
|
|
95
|
+
// An updater that returns its input means "nothing changed", and must not
|
|
96
|
+
// cost a new record: the trial re-renders on record identity, so
|
|
97
|
+
// allocating here turns the standard React bail-out into a render loop.
|
|
98
|
+
if (Object.is(nextState, w.state)) return w;
|
|
92
99
|
return { ...w, state: nextState };
|
|
93
100
|
}),
|
|
94
101
|
}));
|
|
@@ -110,7 +117,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
110
117
|
|
|
111
118
|
updateTrialView: (id, view) => {
|
|
112
119
|
set((s) => ({
|
|
113
|
-
trials: s.trials.map((w) => (w.id === id ? { ...w, view } : w)),
|
|
120
|
+
trials: s.trials.map((w) => (w.id === id && !Object.is(view, w.view) ? { ...w, view } : w)),
|
|
114
121
|
}));
|
|
115
122
|
scheduleFlush();
|
|
116
123
|
},
|
|
@@ -197,6 +204,13 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
197
204
|
scheduleFlush();
|
|
198
205
|
},
|
|
199
206
|
|
|
207
|
+
setLabTool: (id) => set({ activeToolId: id }),
|
|
208
|
+
|
|
209
|
+
setTrialTool: (trialId, id) =>
|
|
210
|
+
set((s) => ({
|
|
211
|
+
trials: s.trials.map((t) => (t.id === trialId ? { ...t, activeToolId: id } : t)),
|
|
212
|
+
})),
|
|
213
|
+
|
|
200
214
|
setLayout: (layout) => {
|
|
201
215
|
set({ layout });
|
|
202
216
|
scheduleFlush();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createLabStore } from './store';
|
|
3
|
+
|
|
4
|
+
function store() {
|
|
5
|
+
return createLabStore({ storageKey: 'test', storage: { read: () => null, write: () => {} } });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const record = { id: 't1', instrumentName: 'X', config: {}, state: {}, view: {} };
|
|
9
|
+
|
|
10
|
+
describe('the tool slot', () => {
|
|
11
|
+
it('starts empty at both levels', () => {
|
|
12
|
+
const s = store();
|
|
13
|
+
expect(s.getState().activeToolId).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sets the lab slot', () => {
|
|
17
|
+
const s = store();
|
|
18
|
+
s.getState().setLabTool('brush');
|
|
19
|
+
expect(s.getState().activeToolId).toBe('brush');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sets a trial slot without touching the lab one', () => {
|
|
23
|
+
const s = store();
|
|
24
|
+
s.getState().addTrial(record);
|
|
25
|
+
s.getState().setLabTool('brush');
|
|
26
|
+
s.getState().setTrialTool('t1', 'eraser');
|
|
27
|
+
expect(s.getState().activeToolId).toBe('brush');
|
|
28
|
+
expect(s.getState().trials[0].activeToolId).toBe('eraser');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('leaves a trial slot undefined until it is set', () => {
|
|
32
|
+
const s = store();
|
|
33
|
+
s.getState().addTrial(record);
|
|
34
|
+
expect(s.getState().trials[0].activeToolId).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/state/types.ts
CHANGED
|
@@ -7,12 +7,16 @@ export interface UndoStack {
|
|
|
7
7
|
|
|
8
8
|
/** One trial as the store holds it: which instrument it runs, that
|
|
9
9
|
* instrument's config and state, the camera, and the undo history. */
|
|
10
|
-
export interface TrialRecord<TS = unknown, TC = unknown> {
|
|
10
|
+
export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
11
11
|
id: string;
|
|
12
12
|
instrumentName: string;
|
|
13
13
|
config: TC;
|
|
14
14
|
state: TS;
|
|
15
|
-
|
|
15
|
+
/** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
|
|
16
|
+
* but never read into. A 3D lab puts an orbit here and keeps all three. */
|
|
17
|
+
view: TV;
|
|
18
|
+
/** This trial's own tool slot. Undefined means it reads the lab's. */
|
|
19
|
+
activeToolId?: string | null;
|
|
16
20
|
undoStack: UndoStack;
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -37,6 +41,8 @@ export interface LabStoreState {
|
|
|
37
41
|
trials: TrialRecord[];
|
|
38
42
|
savedSnapshots: SavedSnapshot[];
|
|
39
43
|
mode: LabMode;
|
|
44
|
+
/** The lab's tool slot — what a trial with no slot of its own resolves to. */
|
|
45
|
+
activeToolId: string | null;
|
|
40
46
|
/** Per-trial tile extents, keyed by trial id. Opaque here — the
|
|
41
47
|
* shape belongs to whatever lays the trials out. */
|
|
42
48
|
layout: Record<string, unknown>;
|
|
@@ -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
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
@import './lab/LabShell.less';
|
|
5
5
|
@import './primitives/Toolbar.less';
|
|
6
6
|
@import './primitives/Sidebar.less';
|
|
7
|
+
@import './primitives/ZoomControl.less';
|
|
7
8
|
@import './primitives/StatusBar.less';
|
|
9
|
+
@import './primitives/JobProgress.less';
|
|
8
10
|
@import './primitives/FpsMeter.less';
|
|
9
11
|
@import './primitives/ScaleIndicator.less';
|
|
12
|
+
@import './primitives/Legend.less';
|
|
13
|
+
@import './primitives/FloatingPanel.less';
|
|
10
14
|
@import './lab/Lab.less';
|
|
11
15
|
@import './trial/Trial.less';
|
|
16
|
+
@import './chrome/regions/ViewportRegion.less';
|
|
17
|
+
@import './chrome/regions/PaletteRegion.less';
|
|
12
18
|
@import './controls/ControlPanel.less';
|
|
13
19
|
@import './canvas/CanvasStack.less';
|
|
14
20
|
@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
|
+
}
|