From 6411983168ba216eb1cd776999e07dba1d0a6bf7 Mon Sep 17 00:00:00 2001 From: Valentin Chanas Date: Mon, 17 Feb 2025 19:09:00 +0100 Subject: [PATCH 1/2] ui-spacetimechart: zoom-rectangle story --- .../stories/rectangle-zoom.stories.tsx | 409 ++++++++++++++++++ .../styles/stories/rectangle-zoom.css | 18 + 2 files changed, 427 insertions(+) create mode 100644 ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx create mode 100644 ui-charts/src/spaceTimeChart/styles/stories/rectangle-zoom.css diff --git a/ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx b/ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx new file mode 100644 index 000000000..ec15e45a6 --- /dev/null +++ b/ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx @@ -0,0 +1,409 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { Button, Slider } from '@osrd-project/ui-core'; +import type { Meta } from '@storybook/react'; +import { clamp } from 'lodash'; + +import '@osrd-project/ui-core/dist/theme.css'; +import '../styles/stories/rectangle-zoom.css'; + +import { OPERATIONAL_POINTS, PATHS } from './lib/paths'; +import { ZoomRect } from '../components/ZoomRect'; +import { PathLayer } from '../components/PathLayer'; +import { SpaceTimeChart } from '../components/SpaceTimeChart'; +import { CAPTION_SIZE } from '../components/TimeCaptions'; +import { type Point, type PathData, type OperationalPoint } from '../lib/types'; +import { getDiff } from '../utils/vectors'; +import { MouseTracker } from './lib/components'; + +const DEFAULT_WIDTH = 1000; +const DEFAULT_HEIGHT = 500; + +const MIN_ZOOM = 0; +const MAX_ZOOM = 100; +const MIN_ZOOM_MS_PER_PX = 600000; +const MAX_ZOOM_MS_PER_PX = 625; +const DEFAULT_ZOOM_MS_PER_PX = 10000; +const MIN_ZOOM_METRE_PER_PX = 10000; +const MAX_ZOOM_METRE_PER_PX = 10; +const DEFAULT_ZOOM_METRE_PER_PX = 300; +type SpaceTimeHorizontalZoomWrapperProps = { + swapAxes: boolean; + spaceOrigin: number; + xOffset: number; + yOffset: number; + operationalPoints: OperationalPoint[]; + paths: (PathData & { color: string })[]; +}; + +const zoomValueToTimeScale = (slider: number) => + MIN_ZOOM_MS_PER_PX * Math.pow(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX, slider / 100); + +const timeScaleToZoomValue = (timeScale: number) => + (100 * Math.log(timeScale / MIN_ZOOM_MS_PER_PX)) / + Math.log(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX); + +const zoomValueToSpaceScale = (slider: number) => + MIN_ZOOM_METRE_PER_PX * Math.pow(MAX_ZOOM_METRE_PER_PX / MIN_ZOOM_METRE_PER_PX, slider / 100); + +const spaceScaleToZoomValue = (spaceScale: number) => + (100 * Math.log(spaceScale / MIN_ZOOM_METRE_PER_PX)) / + Math.log(MAX_ZOOM_METRE_PER_PX / MIN_ZOOM_METRE_PER_PX); + +type StoryState = { + timeZoomValue: number; + spaceZoomValue: number; + xOffset: number; + yOffset: number; + panning: null | { initialOffset: Point }; + zoomMode: boolean; + rect: { + timeStart: Date; + timeEnd: Date; + spaceStart: number; // mm + spaceEnd: number; // mm + } | null; +}; + +/** + * This story demonstrates the behavior of drawing a rectangle with the mouse, and how it should behave in regards to the default zoom and pan. + */ +const RectangleZoomWrapper = ({ + swapAxes, + spaceOrigin, + xOffset, + yOffset, + operationalPoints = [], + paths = [], +}: SpaceTimeHorizontalZoomWrapperProps) => { + const [state, setState] = useState({ + timeZoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX), + spaceZoomValue: spaceScaleToZoomValue(DEFAULT_ZOOM_METRE_PER_PX), + xOffset, + yOffset, + panning: null, + zoomMode: false, + rect: null, + }); + + const timeOrigin = +new Date('2024-04-02T00:00:00'); + const timeScale = zoomValueToTimeScale(state.timeZoomValue); + const spaceScale = [ + { + from: -100000, + to: 100000, + coefficient: zoomValueToSpaceScale(state.spaceZoomValue), // metre/px + }, + ]; + + useEffect(() => { + setState((prev) => ({ ...prev, xOffset })); + }, [xOffset]); + useEffect(() => { + setState((prev) => ({ ...prev, yOffset })); + }, [yOffset]); + + const handleRectangleZoom = useCallback( + ({ + scales: { chosenTimeScale, chosenSpaceScale }, + overrideState, + }: { + scales: { chosenTimeScale: number; chosenSpaceScale: number }; + overrideState?: Partial; + }) => { + setState((prev) => { + if (prev.zoomMode || !prev.rect) { + return prev; + } + + const newTimeScale = clamp(chosenTimeScale, MAX_ZOOM_MS_PER_PX, MIN_ZOOM_MS_PER_PX); + const newSpaceScale = clamp(chosenSpaceScale, MAX_ZOOM_METRE_PER_PX, MIN_ZOOM_METRE_PER_PX); + const timeZoomValue = timeScaleToZoomValue(newTimeScale); + const spaceZoomValue = spaceScaleToZoomValue(newSpaceScale); + + if (!swapAxes) { + const leftRectSide = Math.min(Number(prev.rect.timeStart), Number(prev.rect.timeEnd)); + const topRectSide = Math.min(prev.rect.spaceStart, prev.rect.spaceEnd); + const newXOffset = (timeOrigin - leftRectSide) / newTimeScale; + const newYOffset = (spaceOrigin - topRectSide) / newSpaceScale; + + return { + ...prev, + timeZoomValue: timeZoomValue, + spaceZoomValue: spaceZoomValue, + xOffset: newXOffset, + yOffset: newYOffset, + ...overrideState, + }; + } else { + const leftRectSide = Math.min(prev.rect.spaceStart, prev.rect.spaceEnd); + const topRectSide = Math.min(Number(prev.rect.timeStart), Number(prev.rect.timeEnd)); + const newXOffset = (spaceOrigin - leftRectSide) / newSpaceScale; + const newYOffset = (timeOrigin - topRectSide) / newTimeScale; + + return { + ...prev, + timeZoomValue: timeZoomValue, + spaceZoomValue: spaceZoomValue, + xOffset: newXOffset, + yOffset: newYOffset, + ...overrideState, + }; + } + }); + }, + [swapAxes, timeOrigin, spaceOrigin] + ); + + const handleZoom = useCallback( + ({ + timeZoom = state.timeZoomValue, + spaceZoom = state.spaceZoomValue, + centerPosition: { centerX, centerY } = { + centerX: DEFAULT_WIDTH / 2, + centerY: DEFAULT_HEIGHT / 2, + }, + }: { + timeZoom?: number; + spaceZoom?: number; + centerPosition?: { centerX: number; centerY: number }; + }) => { + setState((prev) => { + if (!(timeZoom && spaceZoom)) { + return prev; + } + + const oldTimeScale = zoomValueToTimeScale(prev.timeZoomValue); + const oldSpaceScale = zoomValueToSpaceScale(prev.spaceZoomValue); + + const boundedTimeZoom = clamp(timeZoom, MIN_ZOOM, MAX_ZOOM); // clamp to [0; 100] + const boundedSpaceZoom = clamp(spaceZoom, MIN_ZOOM, MAX_ZOOM); // clamp to [0; 100] + const newTimeScale = zoomValueToTimeScale(boundedTimeZoom); + const newSpaceScale = zoomValueToSpaceScale(boundedSpaceZoom); + const newXOffset = !swapAxes + ? centerX - ((centerX - prev.xOffset) * oldTimeScale) / newTimeScale + : centerX - ((centerX - prev.xOffset) * oldSpaceScale) / newSpaceScale; + const newYOffset = !swapAxes + ? centerY - ((centerY - prev.yOffset) * oldSpaceScale) / newSpaceScale + : centerY - ((centerY - prev.yOffset) * oldTimeScale) / newTimeScale; + + return { + ...prev, + timeZoomValue: boundedTimeZoom, + spaceZoomValue: boundedSpaceZoom, + xOffset: newXOffset, + yOffset: newYOffset, + }; + }); + }, + [swapAxes, state.timeZoomValue, state.spaceZoomValue] + ); + + useEffect(() => { + if (state.rect && !state.zoomMode) { + const { timeStart, timeEnd, spaceStart, spaceEnd } = state.rect; + const timeRange = Math.abs(Number(timeEnd) - Number(timeStart)); // width of rect in ms + const spaceRange = Math.abs(spaceEnd - spaceStart); // height of rect in metre + const chosenTimeScale = !swapAxes ? timeRange / DEFAULT_WIDTH : timeRange / DEFAULT_HEIGHT; + const chosenSpaceScale = !swapAxes + ? spaceRange / (DEFAULT_HEIGHT - CAPTION_SIZE) + : spaceRange / (DEFAULT_WIDTH - CAPTION_SIZE); + handleRectangleZoom({ + scales: { chosenTimeScale, chosenSpaceScale }, + overrideState: { rect: null }, + }); + } + }, [state.rect, state.zoomMode, swapAxes, handleRectangleZoom]); + + function handleReset(axis: 'x' | 'y') { + if (axis === 'x') { + setState((prev) => ({ + ...prev, + ...(!swapAxes + ? { timeZoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX) } + : { spaceZoomValue: spaceScaleToZoomValue(DEFAULT_ZOOM_METRE_PER_PX) }), + xOffset: 0, + })); + } else { + setState((prev) => ({ + ...prev, + ...(!swapAxes + ? { spaceZoomValue: spaceScaleToZoomValue(DEFAULT_ZOOM_METRE_PER_PX) } + : { timeZoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX) }), + yOffset: 0, + })); + } + } + const simpleOperationalPoints = operationalPoints.map(({ id, position }) => ({ + id, + label: id, + position, + })); + + return ( +
+ { + handleZoom({ + timeZoom: state.timeZoomValue + delta, + spaceZoom: state.spaceZoomValue + delta, + centerPosition: { centerX: x, centerY: y }, + }); + }} + onPan={({ initialPosition, position, isPanning, data, initialData }) => { + const diff = getDiff(initialPosition, position); + setState((prev) => { + // when releasing the mouse, onPan is called one last time with isPanning false + if (!isPanning) { + return { ...prev, panning: null, zoomMode: false }; + } + if (state.zoomMode) { + const rect = { + timeStart: new Date(initialData.time), + timeEnd: new Date(data.time), + spaceStart: initialData.position, + spaceEnd: data.position, + }; + + return { + ...prev, + rect, + }; + } + // Start panning: + else if (!prev.panning) { + return { + ...prev, + panning: { + initialOffset: { + x: prev.xOffset, + y: prev.yOffset, + }, + }, + }; + } + // Keep panning: + else { + const { initialOffset } = prev.panning; + return { + ...prev, + xOffset: initialOffset.x + diff.x, + yOffset: initialOffset.y + diff.y, + }; + } + }); + }} + > + {paths.map((path) => ( + + ))} + {state.rect && ( + + )} + + +
+
offset: {state.yOffset.toFixed(0)}
+
+ {!swapAxes + ? `spaceScale: ${zoomValueToSpaceScale(state.spaceZoomValue).toFixed(2)} m/px` + : `timeScale: ${zoomValueToTimeScale(state.timeZoomValue).toFixed(2)} ms/px`} +
+ + +
+

Horizontal

+
+ { + handleZoom({ timeZoom: Number(e.target.value) }); + }} + /> +
+
offset: {state.xOffset.toFixed(0)}
+
+ {!swapAxes + ? `timeScale: ${zoomValueToTimeScale(state.timeZoomValue).toFixed(2)} ms/px` + : `spaceScale: ${zoomValueToSpaceScale(state.spaceZoomValue).toFixed(2)} m/px`} +
+
+ + + ); +}; + +export default { + title: 'SpaceTimeChart/Zoom rectangle', + component: RectangleZoomWrapper, + // tags: ['autodocs'], +} as Meta; + +export const Default = { + args: { + swapAxes: false, + spaceOrigin: 0, + xOffset: 0, + yOffset: 0, + operationalPoints: OPERATIONAL_POINTS, + paths: PATHS.slice(1, 2), + }, +}; diff --git a/ui-charts/src/spaceTimeChart/styles/stories/rectangle-zoom.css b/ui-charts/src/spaceTimeChart/styles/stories/rectangle-zoom.css new file mode 100644 index 000000000..98c88e85f --- /dev/null +++ b/ui-charts/src/spaceTimeChart/styles/stories/rectangle-zoom.css @@ -0,0 +1,18 @@ +.rectangle-zoom-story-wrapper { + .bottom-control-buttons { + display: flex; + flex-direction: row; + gap: 15px; + + .control-side { + display: flex; + flex-direction: column; + gap: 2px; + padding: 7px; + + h3 { + font-weight: 700; + } + } + } +} From 55940122e6c1e1217927355d0ceaf36bbfc61773 Mon Sep 17 00:00:00 2001 From: Valentin Chanas Date: Wed, 19 Feb 2025 14:40:49 +0100 Subject: [PATCH 2/2] ui-spacetimechart: improve data label data label would disappear if we go too close to the right or bottom border of the canvas. the label now shifts above or left of the mouse to stay visible --- .../src/spaceTimeChart/stories/lib/components.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ui-charts/src/spaceTimeChart/stories/lib/components.tsx b/ui-charts/src/spaceTimeChart/stories/lib/components.tsx index 485ede84a..b4983010e 100644 --- a/ui-charts/src/spaceTimeChart/stories/lib/components.tsx +++ b/ui-charts/src/spaceTimeChart/stories/lib/components.tsx @@ -58,11 +58,15 @@ const DataLabel = ({ position, isDiff, marginTop = 0, + shiftTextX = 0, + shiftTextY = 0, }: { data: DataPoint; position: Point; isDiff?: boolean; marginTop?: number; + shiftTextX?: number; + shiftTextY?: number; }) => (
-
+
{isDiff ? ( <>
Time difference: {formatTimeLength(new Date(data.time))}
@@ -95,7 +102,7 @@ const DataLabel = ({ * This component renders a DataLabel under the mouse, using the MouseContext from the SpaceTimeChart: */ export const MouseTracker = ({ reference }: { reference?: DataPoint }) => { - const { getPoint } = useContext(SpaceTimeChartContext); + const { getPoint, width, height } = useContext(SpaceTimeChartContext); const { position, data, isHover } = useContext(MouseContext); return isHover ? ( @@ -113,7 +120,8 @@ export const MouseTracker = ({ reference }: { reference?: DataPoint }) => { } position={position} isDiff={!!reference} - marginTop={30} + shiftTextX={position.x > width - 100 ? -100 : 10} + shiftTextY={position.y > height - 50 ? -50 : 20} /> ) : null;