@dxos/react-ui-canvas 0.7.5-feature-compute.4d9d99a
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/LICENSE +8 -0
- package/README.md +1 -0
- package/dist/lib/browser/index.mjs +605 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node/index.cjs +641 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node-esm/index.mjs +607 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/src/components/Canvas/Canvas.d.ts +15 -0
- package/dist/types/src/components/Canvas/Canvas.d.ts.map +1 -0
- package/dist/types/src/components/Canvas/Canvas.stories.d.ts +9 -0
- package/dist/types/src/components/Canvas/Canvas.stories.d.ts.map +1 -0
- package/dist/types/src/components/Canvas/index.d.ts +2 -0
- package/dist/types/src/components/Canvas/index.d.ts.map +1 -0
- package/dist/types/src/components/FPS.d.ts +9 -0
- package/dist/types/src/components/FPS.d.ts.map +1 -0
- package/dist/types/src/components/Grid/Grid.d.ts +19 -0
- package/dist/types/src/components/Grid/Grid.d.ts.map +1 -0
- package/dist/types/src/components/Grid/Grid.stories.d.ts +8 -0
- package/dist/types/src/components/Grid/Grid.stories.d.ts.map +1 -0
- package/dist/types/src/components/Grid/index.d.ts +2 -0
- package/dist/types/src/components/Grid/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +4 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +4 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/projection.d.ts +58 -0
- package/dist/types/src/hooks/projection.d.ts.map +1 -0
- package/dist/types/src/hooks/useCanvasContext.d.ts +13 -0
- package/dist/types/src/hooks/useCanvasContext.d.ts.map +1 -0
- package/dist/types/src/hooks/useWheel.d.ts +8 -0
- package/dist/types/src/hooks/useWheel.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +20 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/src/util/index.d.ts +3 -0
- package/dist/types/src/util/index.d.ts.map +1 -0
- package/dist/types/src/util/svg.d.ts +33 -0
- package/dist/types/src/util/svg.d.ts.map +1 -0
- package/dist/types/src/util/svg.stories.d.ts +6 -0
- package/dist/types/src/util/svg.stories.d.ts.map +1 -0
- package/dist/types/src/util/util.d.ts +17 -0
- package/dist/types/src/util/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +61 -0
- package/src/components/Canvas/Canvas.stories.tsx +109 -0
- package/src/components/Canvas/Canvas.tsx +89 -0
- package/src/components/Canvas/index.ts +5 -0
- package/src/components/FPS.tsx +98 -0
- package/src/components/Grid/Grid.stories.tsx +41 -0
- package/src/components/Grid/Grid.tsx +87 -0
- package/src/components/Grid/index.ts +5 -0
- package/src/components/index.ts +7 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/projection.tsx +156 -0
- package/src/hooks/useCanvasContext.tsx +29 -0
- package/src/hooks/useWheel.tsx +107 -0
- package/src/index.ts +8 -0
- package/src/types.ts +13 -0
- package/src/util/index.ts +6 -0
- package/src/util/svg.stories.tsx +45 -0
- package/src/util/svg.tsx +131 -0
- package/src/util/util.ts +50 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
11
|
+
|
|
12
|
+
import { Canvas } from './Canvas';
|
|
13
|
+
import { useCanvasContext, useWheel } from '../../hooks';
|
|
14
|
+
import { type Point } from '../../types';
|
|
15
|
+
import { testId } from '../../util';
|
|
16
|
+
import { Grid, type GridProps } from '../Grid';
|
|
17
|
+
|
|
18
|
+
const size = 128;
|
|
19
|
+
|
|
20
|
+
const points: Point[] = [0, (2 * Math.PI) / 3, (2 * Math.PI * 2) / 3].map((a, i) => ({
|
|
21
|
+
x: Math.round(Math.cos(a - Math.PI / 2) * size * 1.5),
|
|
22
|
+
y: Math.round(Math.sin(a - Math.PI / 2) * size * 1.5),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const Render = (props: GridProps) => {
|
|
26
|
+
return (
|
|
27
|
+
<Canvas>
|
|
28
|
+
<Grid {...props} />
|
|
29
|
+
<Content />
|
|
30
|
+
</Canvas>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const TwoCanvases = (props: GridProps) => {
|
|
35
|
+
return (
|
|
36
|
+
<div className='grid grid-cols-2 gap-2 w-full h-full'>
|
|
37
|
+
<div className='h-full relative'>
|
|
38
|
+
<Canvas>
|
|
39
|
+
<Grid {...props} />
|
|
40
|
+
<Content />
|
|
41
|
+
</Canvas>
|
|
42
|
+
</div>
|
|
43
|
+
<div className='h-full relative'>
|
|
44
|
+
<Canvas>
|
|
45
|
+
<Grid {...props} />
|
|
46
|
+
<Content />
|
|
47
|
+
</Canvas>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const Content = () => {
|
|
54
|
+
useWheel();
|
|
55
|
+
return (
|
|
56
|
+
<div>
|
|
57
|
+
{points.map(({ x, y }, i) => (
|
|
58
|
+
<Item key={i} x={x} y={y} />
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const Item = (p: Point) => {
|
|
65
|
+
const { projection } = useCanvasContext();
|
|
66
|
+
const r = (projection.scale * size) / 2;
|
|
67
|
+
const [{ x, y }] = projection.toScreen([p]);
|
|
68
|
+
const rect = {
|
|
69
|
+
left: x - size / 2,
|
|
70
|
+
top: y - size / 2,
|
|
71
|
+
width: size,
|
|
72
|
+
height: size,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div {...testId('dx-test', true)}>
|
|
77
|
+
<div className='absolute flex justify-center items-center' style={rect}>
|
|
78
|
+
<div className='font-mono'>
|
|
79
|
+
({p.x},{p.y})
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* NOTE: Width and height are not important since overflow-visible. */}
|
|
84
|
+
<svg className='absolute overflow-visible'>
|
|
85
|
+
<circle cx={x} cy={y} r={r} className='stroke-red-500 storke-width-2 fill-none' />
|
|
86
|
+
</svg>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const meta: Meta<GridProps> = {
|
|
92
|
+
title: 'ui/react-ui-canvas/Canvas',
|
|
93
|
+
component: Grid,
|
|
94
|
+
render: Render,
|
|
95
|
+
decorators: [withTheme, withLayout({ fullscreen: true })],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export default meta;
|
|
99
|
+
|
|
100
|
+
type Story = StoryObj<GridProps>;
|
|
101
|
+
|
|
102
|
+
export const Default: Story = {
|
|
103
|
+
args: { size: 16 },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const SideBySide: Story = {
|
|
107
|
+
args: { size: 16 },
|
|
108
|
+
render: TwoCanvases,
|
|
109
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, {
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type HTMLAttributes,
|
|
8
|
+
type PropsWithChildren,
|
|
9
|
+
forwardRef,
|
|
10
|
+
useEffect,
|
|
11
|
+
useImperativeHandle,
|
|
12
|
+
useMemo,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react';
|
|
15
|
+
import { useResizeDetector } from 'react-resize-detector';
|
|
16
|
+
|
|
17
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
18
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
19
|
+
|
|
20
|
+
import { defaultOrigin, CanvasContext, ProjectionMapper, type ProjectionState } from '../../hooks';
|
|
21
|
+
|
|
22
|
+
export interface CanvasController {
|
|
23
|
+
setProjection(projection: ProjectionState): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CanvasProps = ThemedClassName<PropsWithChildren<Partial<ProjectionState> & HTMLAttributes<HTMLDivElement>>>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Root canvas component.
|
|
30
|
+
* Manages CSS projection.
|
|
31
|
+
*/
|
|
32
|
+
export const Canvas = forwardRef<CanvasController, CanvasProps>(
|
|
33
|
+
({ children, classNames, scale: _scale = 1, offset: _offset = defaultOrigin, ...props }, forwardedRef) => {
|
|
34
|
+
// Size.
|
|
35
|
+
const { ref, width = 0, height = 0 } = useResizeDetector();
|
|
36
|
+
|
|
37
|
+
// Ready when initially resized.
|
|
38
|
+
const [ready, setReady] = useState(false);
|
|
39
|
+
|
|
40
|
+
// Projection.
|
|
41
|
+
const [{ scale, offset }, setProjection] = useState<ProjectionState>({ scale: _scale, offset: _offset });
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (width && height && offset === defaultOrigin) {
|
|
44
|
+
setProjection({ scale, offset: { x: width / 2, y: height / 2 } });
|
|
45
|
+
}
|
|
46
|
+
}, [width, height, scale, offset]);
|
|
47
|
+
|
|
48
|
+
// Projection mapper.
|
|
49
|
+
const projection = useMemo(() => new ProjectionMapper(), []);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
projection.update({ width, height }, scale, offset);
|
|
52
|
+
if (offset !== defaultOrigin) {
|
|
53
|
+
setReady(true);
|
|
54
|
+
}
|
|
55
|
+
}, [projection, scale, offset, width, height]);
|
|
56
|
+
|
|
57
|
+
// CSS transforms.
|
|
58
|
+
const styles = useMemo<CSSProperties>(() => {
|
|
59
|
+
return {
|
|
60
|
+
// NOTE: Order is important.
|
|
61
|
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
|
62
|
+
visibility: width && height ? 'visible' : 'hidden',
|
|
63
|
+
};
|
|
64
|
+
}, [scale, offset]);
|
|
65
|
+
|
|
66
|
+
// Controller.
|
|
67
|
+
useImperativeHandle(
|
|
68
|
+
forwardedRef,
|
|
69
|
+
() => {
|
|
70
|
+
return {
|
|
71
|
+
setProjection: async (projection: ProjectionState) => {
|
|
72
|
+
setProjection(projection);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
[ref],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<CanvasContext.Provider
|
|
81
|
+
value={{ root: ref.current, ready, width, height, scale, offset, styles, projection, setProjection }}
|
|
82
|
+
>
|
|
83
|
+
<div role='none' {...props} className={mx('absolute inset-0 overflow-hidden', classNames)} ref={ref}>
|
|
84
|
+
{ready ? children : null}
|
|
85
|
+
</div>
|
|
86
|
+
</CanvasContext.Provider>
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
// Adapted from: https://github.com/smplrspace/react-fps-stats
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import React, { useEffect, useReducer, useRef } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
9
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
10
|
+
|
|
11
|
+
export type FPSProps = ThemedClassName<{
|
|
12
|
+
width?: number;
|
|
13
|
+
height?: number;
|
|
14
|
+
bar?: string;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
type State = {
|
|
18
|
+
max: number;
|
|
19
|
+
len: number;
|
|
20
|
+
fps: number[];
|
|
21
|
+
frames: number;
|
|
22
|
+
prevTime: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SEC = 1_000;
|
|
26
|
+
|
|
27
|
+
export const FPS = ({ classNames, width = 60, height = 30, bar = 'bg-cyan-500' }: FPSProps) => {
|
|
28
|
+
const [{ fps, max, len }, dispatch] = useReducer(
|
|
29
|
+
(state: State) => {
|
|
30
|
+
const currentTime = Date.now();
|
|
31
|
+
if (currentTime > state.prevTime + SEC) {
|
|
32
|
+
const nextFPS = [
|
|
33
|
+
...new Array(Math.floor((currentTime - state.prevTime - SEC) / SEC)).fill(0),
|
|
34
|
+
Math.max(1, Math.round((state.frames * SEC) / (currentTime - state.prevTime))),
|
|
35
|
+
];
|
|
36
|
+
return {
|
|
37
|
+
max: Math.max(state.max, ...nextFPS),
|
|
38
|
+
len: Math.min(state.len + nextFPS.length, width),
|
|
39
|
+
fps: [...state.fps, ...nextFPS].slice(-width),
|
|
40
|
+
frames: 1,
|
|
41
|
+
prevTime: currentTime,
|
|
42
|
+
};
|
|
43
|
+
} else {
|
|
44
|
+
return { ...state, frames: state.frames + 1 };
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
max: 0,
|
|
49
|
+
len: 0,
|
|
50
|
+
fps: [],
|
|
51
|
+
frames: 0,
|
|
52
|
+
prevTime: Date.now(),
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const requestRef = useRef<number>();
|
|
57
|
+
const tick = () => {
|
|
58
|
+
dispatch();
|
|
59
|
+
requestRef.current = requestAnimationFrame(tick);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
requestRef.current = requestAnimationFrame(tick);
|
|
64
|
+
return () => {
|
|
65
|
+
if (requestRef.current) {
|
|
66
|
+
cancelAnimationFrame(requestRef.current);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
style={{ width: width + 6 }}
|
|
74
|
+
className={mx(
|
|
75
|
+
'relative flex flex-col p-0.5',
|
|
76
|
+
'bg-base text-xs text-subdued font-thin pointer-events-none border border-separator',
|
|
77
|
+
classNames,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<div>{fps[len - 1]} FPS</div>
|
|
81
|
+
<div className='w-full relative' style={{ height }}>
|
|
82
|
+
{fps.map((frame, i) => (
|
|
83
|
+
<div
|
|
84
|
+
key={`fps-${i}`}
|
|
85
|
+
className={bar}
|
|
86
|
+
style={{
|
|
87
|
+
position: 'absolute',
|
|
88
|
+
bottom: 0,
|
|
89
|
+
right: `${len - 1 - i}px`,
|
|
90
|
+
height: `${(height * frame) / max}px`,
|
|
91
|
+
width: 1,
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
8
|
+
import React, { useRef, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
11
|
+
|
|
12
|
+
import { GridComponent, type GridProps } from './Grid';
|
|
13
|
+
import { type ProjectionState } from '../../hooks';
|
|
14
|
+
|
|
15
|
+
const Render = (props: GridProps) => {
|
|
16
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
17
|
+
const [{ scale, offset }] = useState<ProjectionState>({ scale: 1, offset: { x: 0, y: 0 } });
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div ref={ref} className='grow'>
|
|
21
|
+
<GridComponent scale={scale} offset={offset} {...props} />
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const meta: Meta<GridProps> = {
|
|
27
|
+
title: 'ui/react-ui-canvas/Grid',
|
|
28
|
+
component: GridComponent,
|
|
29
|
+
render: Render,
|
|
30
|
+
decorators: [withTheme, withLayout({ fullscreen: true })],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default meta;
|
|
34
|
+
|
|
35
|
+
type Story = StoryObj<GridProps>;
|
|
36
|
+
|
|
37
|
+
export const Default: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
size: 16,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { forwardRef, useMemo, useId } from 'react';
|
|
6
|
+
|
|
7
|
+
import { useForwardedRef, type ThemedClassName } from '@dxos/react-ui';
|
|
8
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
9
|
+
|
|
10
|
+
import { useCanvasContext } from '../../hooks';
|
|
11
|
+
import { type Point } from '../../types';
|
|
12
|
+
import { GridPattern, testId } from '../../util';
|
|
13
|
+
|
|
14
|
+
const gridRatios = [1 / 4, 1, 4, 16];
|
|
15
|
+
|
|
16
|
+
const defaultGridSize = 16;
|
|
17
|
+
const defaultOffset: Point = { x: 0, y: 0 };
|
|
18
|
+
|
|
19
|
+
const createId = (parent: string, grid: number) => `dx-canvas-grid-${parent}-${grid}`;
|
|
20
|
+
|
|
21
|
+
export type GridProps = ThemedClassName<{
|
|
22
|
+
size?: number;
|
|
23
|
+
scale?: number;
|
|
24
|
+
offset?: Point;
|
|
25
|
+
showAxes?: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
export const GridComponent = forwardRef<SVGSVGElement, GridProps>(
|
|
29
|
+
(
|
|
30
|
+
{ size: gridSize = defaultGridSize, scale = 1, offset = defaultOffset, showAxes = true, classNames },
|
|
31
|
+
forwardedRef,
|
|
32
|
+
) => {
|
|
33
|
+
const svgRef = useForwardedRef(forwardedRef);
|
|
34
|
+
const instanceId = useId();
|
|
35
|
+
const grids = useMemo(
|
|
36
|
+
() =>
|
|
37
|
+
gridRatios
|
|
38
|
+
.map((ratio) => ({ id: ratio, size: ratio * gridSize * scale }))
|
|
39
|
+
.filter(({ size }) => size >= gridSize && size <= 256),
|
|
40
|
+
[gridSize, scale],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const { width = 0, height = 0 } = svgRef.current?.getBoundingClientRect() ?? {};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<svg
|
|
47
|
+
{...testId('dx-canvas-grid')}
|
|
48
|
+
ref={svgRef}
|
|
49
|
+
className={mx(
|
|
50
|
+
'absolute inset-0 w-full h-full pointer-events-none touch-none select-none',
|
|
51
|
+
'stroke-neutral-500',
|
|
52
|
+
classNames,
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{/* NOTE: The pattern is offset so that the middle of the pattern aligns with the grid. */}
|
|
56
|
+
<defs>
|
|
57
|
+
{grids.map(({ id, size }) => (
|
|
58
|
+
<GridPattern key={id} id={createId(instanceId, id)} offset={offset} size={size} />
|
|
59
|
+
))}
|
|
60
|
+
</defs>
|
|
61
|
+
{showAxes && (
|
|
62
|
+
<>
|
|
63
|
+
<line x1={0} y1={offset.y} x2={width} y2={offset.y} className='stroke-neutral-500 opacity-40' />
|
|
64
|
+
<line x1={offset.x} y1={0} x2={offset.x} y2={height} className='stroke-neutral-500 opacity-40' />
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
<g>
|
|
68
|
+
{grids.map(({ id }, i) => (
|
|
69
|
+
<rect
|
|
70
|
+
key={id}
|
|
71
|
+
opacity={0.1 + i * 0.05}
|
|
72
|
+
fill={`url(#${createId(instanceId, id)})`}
|
|
73
|
+
width='100%'
|
|
74
|
+
height='100%'
|
|
75
|
+
/>
|
|
76
|
+
))}
|
|
77
|
+
</g>
|
|
78
|
+
</svg>
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// TODO(burdon): Use id of parent canvas.
|
|
84
|
+
export const Grid = (props: GridProps) => {
|
|
85
|
+
const { scale, offset } = useCanvasContext();
|
|
86
|
+
return <GridComponent {...props} scale={scale} offset={offset} />;
|
|
87
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as d3 from 'd3';
|
|
6
|
+
import {
|
|
7
|
+
type Matrix,
|
|
8
|
+
applyToPoints,
|
|
9
|
+
compose,
|
|
10
|
+
identity,
|
|
11
|
+
inverse,
|
|
12
|
+
scale as scaleMatrix,
|
|
13
|
+
translate as translateMatrix,
|
|
14
|
+
} from 'transformation-matrix';
|
|
15
|
+
|
|
16
|
+
import { type Point, type Dimension } from '../types';
|
|
17
|
+
|
|
18
|
+
export const defaultOrigin: Point = { x: 0, y: 0 };
|
|
19
|
+
|
|
20
|
+
// TODO(burdon): Rotation also?
|
|
21
|
+
export type ProjectionState = {
|
|
22
|
+
scale: number;
|
|
23
|
+
offset: Point;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maps between screen and model coordinates.
|
|
28
|
+
*/
|
|
29
|
+
export interface Projection {
|
|
30
|
+
get bounds(): Dimension;
|
|
31
|
+
get scale(): number;
|
|
32
|
+
get offset(): Point;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Maps the model space to the screen offset (from the top-left of the element).
|
|
36
|
+
*/
|
|
37
|
+
toScreen(points: Point[]): Point[];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Maps the pointer coordinate (from the top-left of the element) to the model space.
|
|
41
|
+
*/
|
|
42
|
+
toModel(points: Point[]): Point[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ProjectionMapper implements Projection {
|
|
46
|
+
private _bounds: Dimension = { width: 0, height: 0 };
|
|
47
|
+
private _scale: number = 1;
|
|
48
|
+
private _offset: Point = defaultOrigin;
|
|
49
|
+
private _toScreen: Matrix = identity();
|
|
50
|
+
private _toModel: Matrix = identity();
|
|
51
|
+
|
|
52
|
+
constructor(bounds?: Dimension, scale?: number, offset?: Point) {
|
|
53
|
+
if (bounds && scale && offset) {
|
|
54
|
+
this.update(bounds, scale, offset);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
update(bounds: Dimension, scale: number, offset: Point) {
|
|
59
|
+
this._bounds = bounds;
|
|
60
|
+
this._scale = scale;
|
|
61
|
+
this._offset = offset;
|
|
62
|
+
this._toScreen = compose(
|
|
63
|
+
// NOTE: Order is important.
|
|
64
|
+
translateMatrix(this._offset.x, this._offset.y),
|
|
65
|
+
scaleMatrix(this._scale),
|
|
66
|
+
// TODO(burdon): Flip.
|
|
67
|
+
// flipX(),
|
|
68
|
+
);
|
|
69
|
+
this._toModel = inverse(this._toScreen);
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get bounds() {
|
|
74
|
+
return this._bounds;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get scale() {
|
|
78
|
+
return this._scale;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get offset() {
|
|
82
|
+
return this._offset;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
toScreen(points: Point[]): Point[] {
|
|
86
|
+
return applyToPoints(this._toScreen, points);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
toModel(points: Point[]): Point[] {
|
|
90
|
+
return applyToPoints(this._toModel, points);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Maintain position while zooming.
|
|
96
|
+
*/
|
|
97
|
+
export const getZoomTransform = ({
|
|
98
|
+
scale,
|
|
99
|
+
offset,
|
|
100
|
+
pos,
|
|
101
|
+
newScale,
|
|
102
|
+
}: ProjectionState & { pos: Point; newScale: number }): ProjectionState => {
|
|
103
|
+
return {
|
|
104
|
+
scale: newScale,
|
|
105
|
+
offset: {
|
|
106
|
+
x: pos.x - (pos.x - offset.x) * (newScale / scale),
|
|
107
|
+
y: pos.y - (pos.y - offset.y) * (newScale / scale),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Zoom while keeping the specified position in place.
|
|
114
|
+
*/
|
|
115
|
+
// TODO(burdon): Convert to object.
|
|
116
|
+
export const zoomInPlace = (
|
|
117
|
+
setTransform: (state: ProjectionState) => void,
|
|
118
|
+
pos: Point,
|
|
119
|
+
offset: Point,
|
|
120
|
+
current: number,
|
|
121
|
+
next: number,
|
|
122
|
+
delay = 200,
|
|
123
|
+
) => {
|
|
124
|
+
const is = d3.interpolate(current, next);
|
|
125
|
+
d3.transition()
|
|
126
|
+
.ease(d3.easeSinOut)
|
|
127
|
+
.duration(delay)
|
|
128
|
+
.tween('zoom', () => (t) => {
|
|
129
|
+
const newScale = is(t);
|
|
130
|
+
setTransform(getZoomTransform({ scale: current, newScale, offset, pos }));
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const noop = () => {};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Zoom to new scale and position.
|
|
138
|
+
*/
|
|
139
|
+
// TODO(burdon): Convert to object.
|
|
140
|
+
export const zoomTo = (
|
|
141
|
+
setTransform: (state: ProjectionState) => void,
|
|
142
|
+
current: ProjectionState,
|
|
143
|
+
next: ProjectionState,
|
|
144
|
+
delay = 200,
|
|
145
|
+
cb = noop,
|
|
146
|
+
) => {
|
|
147
|
+
const is = d3.interpolateObject({ scale: current.scale, ...current.offset }, { scale: next.scale, ...next.offset });
|
|
148
|
+
d3.transition()
|
|
149
|
+
.ease(d3.easeSinOut)
|
|
150
|
+
.duration(delay)
|
|
151
|
+
.tween('zoom', () => (t) => {
|
|
152
|
+
const { scale, x, y } = is(t);
|
|
153
|
+
setTransform({ scale, offset: { x, y } });
|
|
154
|
+
})
|
|
155
|
+
.on('end', cb);
|
|
156
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type CSSProperties, type Dispatch, type SetStateAction, createContext, useContext } from 'react';
|
|
6
|
+
|
|
7
|
+
import { raise } from '@dxos/debug';
|
|
8
|
+
|
|
9
|
+
import { type Projection, type ProjectionState } from './projection';
|
|
10
|
+
|
|
11
|
+
export type CanvasContext = ProjectionState & {
|
|
12
|
+
root: HTMLDivElement;
|
|
13
|
+
ready: boolean;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
styles: CSSProperties;
|
|
17
|
+
projection: Projection;
|
|
18
|
+
setProjection: Dispatch<SetStateAction<ProjectionState>>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
// TODO(burdon): Use radix?
|
|
25
|
+
export const CanvasContext = createContext<CanvasContext | null>(null);
|
|
26
|
+
|
|
27
|
+
export const useCanvasContext = (): CanvasContext => {
|
|
28
|
+
return useContext(CanvasContext) ?? raise(new Error('Missing CanvasContext'));
|
|
29
|
+
};
|