Skip to content

Commit da20190

Browse files
committed
ui-space-time: zoom behavior mouse + slider
Signed-off-by: Valentin Chanas <[email protected]>
1 parent 5539323 commit da20190

File tree

1 file changed

+170
-0
lines changed

1 file changed

+170
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import React, { useEffect, useState } from 'react';
2+
3+
import { Button, Slider } from '@osrd-project/ui-core';
4+
import type { Meta } from '@storybook/react';
5+
6+
import '@osrd-project/ui-core/dist/theme.css';
7+
8+
import { OPERATIONAL_POINTS, PATHS } from './lib/paths';
9+
import { PathLayer } from '../components/PathLayer';
10+
import { SpaceTimeChart } from '../components/SpaceTimeChart';
11+
import { type Point, type PathData, type OperationalPoint } from '../lib/types';
12+
import { getDiff } from '../utils/vectors';
13+
14+
const DEFAULT_WIDTH = 1200;
15+
const DEFAULT_HEIGHT = 550;
16+
17+
const MIN_ZOOM = 0;
18+
const MAX_ZOOM = 100;
19+
const MIN_ZOOM_MS_PER_PX = 600000;
20+
const MAX_ZOOM_MS_PER_PX = 625;
21+
const DEFAULT_ZOOM_MS_PER_PX = 7500;
22+
type SpaceTimeHorizontalZoomWrapperProps = {
23+
offset: number;
24+
operationalPoints: OperationalPoint[];
25+
paths: (PathData & { color: string })[];
26+
};
27+
28+
const zoomValueToTimeScale = (slider: number) =>
29+
MIN_ZOOM_MS_PER_PX * Math.pow(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX, slider / 100);
30+
31+
const timeScaleToZoomValue = (timeScale: number) =>
32+
(100 * Math.log(timeScale / MIN_ZOOM_MS_PER_PX)) /
33+
Math.log(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX);
34+
35+
const SpaceTimeHorizontalZoomWrapper = ({
36+
offset,
37+
operationalPoints = [],
38+
paths = [],
39+
}: SpaceTimeHorizontalZoomWrapperProps) => {
40+
const [state, setState] = useState<{
41+
zoomValue: number;
42+
xOffset: number;
43+
yOffset: number;
44+
panning: null | { initialOffset: Point };
45+
}>({
46+
zoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX),
47+
xOffset: offset,
48+
yOffset: 0,
49+
panning: null,
50+
});
51+
useEffect(() => {
52+
setState((prev) => ({ ...prev, xOffset: offset }));
53+
}, [offset]);
54+
const handleZoom = (zoomValue: number, position = DEFAULT_WIDTH / 2) => {
55+
if (MIN_ZOOM <= zoomValue && zoomValue <= MAX_ZOOM) {
56+
const oldTimeScale = zoomValueToTimeScale(state.zoomValue);
57+
const newTimeScale = zoomValueToTimeScale(zoomValue);
58+
// by default zooming expands the graph around timeOrigin
59+
// changing the offset to timeOriginShift alone keeps the left border in place.
60+
// subtracting centerShift keeps the center in place
61+
// (xOffset = how many px the timeOrigin is shifted to the right)
62+
const timeOriginShift = (state.xOffset * oldTimeScale) / newTimeScale;
63+
const centerShift = (position * oldTimeScale - position * newTimeScale) / newTimeScale;
64+
const newOffset = timeOriginShift - centerShift;
65+
setState((prev) => ({ ...prev, zoomValue, xOffset: newOffset }));
66+
}
67+
};
68+
const simpleOperationalPoints = operationalPoints.map(({ id, position }) => ({
69+
id,
70+
label: id,
71+
position,
72+
}));
73+
const spaceScale = [
74+
{
75+
from: 0,
76+
to: 75000,
77+
coefficient: 300,
78+
},
79+
];
80+
return (
81+
<div
82+
className="space-time-horizontal-zoom-wrapper"
83+
style={{
84+
height: `${DEFAULT_HEIGHT}px`,
85+
width: `${DEFAULT_WIDTH}px`,
86+
}}
87+
>
88+
<SpaceTimeChart
89+
className="inset-0 absolute h-full"
90+
spaceOrigin={0}
91+
xOffset={state.xOffset}
92+
yOffset={state.yOffset}
93+
timeOrigin={+new Date('2024/04/02')}
94+
operationalPoints={simpleOperationalPoints}
95+
timeScale={zoomValueToTimeScale(state.zoomValue)}
96+
spaceScales={spaceScale}
97+
onZoom={({ delta, position: { x } }) => {
98+
handleZoom(state.zoomValue + delta, x);
99+
}}
100+
onPan={({ initialPosition, position, isPanning }) => {
101+
const diff = getDiff(initialPosition, position);
102+
setState((s) => {
103+
// Stop panning:
104+
if (!isPanning) {
105+
return { ...s, panning: null };
106+
}
107+
// Start panning:
108+
else if (!s.panning) {
109+
return {
110+
...s,
111+
panning: {
112+
initialOffset: {
113+
x: s.xOffset,
114+
y: s.yOffset,
115+
},
116+
},
117+
};
118+
}
119+
// Keep panning:
120+
else {
121+
const { initialOffset } = s.panning;
122+
return {
123+
...s,
124+
xOffset: initialOffset.x + diff.x,
125+
yOffset: initialOffset.y + diff.y,
126+
};
127+
}
128+
});
129+
}}
130+
>
131+
{paths.map((path) => (
132+
<PathLayer key={path.id} path={path} color={path.color} />
133+
))}
134+
</SpaceTimeChart>
135+
<div className="flex flex-col gap-1">
136+
<div className="flex flex-row items-center gap-5">
137+
<Slider
138+
min={0}
139+
max={100}
140+
value={state.zoomValue}
141+
onChange={(e) => {
142+
handleZoom(Number(e.target.value));
143+
}}
144+
/>
145+
<Button
146+
label="reset"
147+
onClick={() => {
148+
handleZoom(timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX));
149+
}}
150+
/>
151+
</div>
152+
<div>offset: {state.xOffset}</div>
153+
<div>timescale: {zoomValueToTimeScale(state.zoomValue)} ms/px</div>
154+
</div>
155+
</div>
156+
);
157+
};
158+
159+
export default {
160+
title: 'SpaceTimeChart/Horiontal Zoom',
161+
component: SpaceTimeHorizontalZoomWrapper,
162+
} as Meta<typeof SpaceTimeHorizontalZoomWrapper>;
163+
164+
export const Default = {
165+
args: {
166+
offset: 0,
167+
operationalPoints: OPERATIONAL_POINTS,
168+
paths: PATHS.slice(2, 4),
169+
},
170+
};

0 commit comments

Comments
 (0)