From c9db3729b848d89a701962202cd3bf0a574995da Mon Sep 17 00:00:00 2001 From: Valentin Chanas Date: Wed, 5 Mar 2025 13:21:17 +0100 Subject: [PATCH] ui-manchette-with-spacetime-chart: story: zoom on manchette + spacetime Signed-off-by: Valentin Chanas --- .../src/__tests__/helpers.spec.ts | 44 ++++++++- .../src/consts.ts | 9 +- .../src/helpers.ts | 95 +++++++++++++----- .../hooks/useManchetteWithSpaceTimeChart.ts | 98 ++++++++++++++----- .../src/stories/base.stories.tsx | 2 - .../src/stories/rectangle-zoom.stories.tsx | 29 ++++-- .../src/styles/stories/rectangle-zoom.css | 26 +++++ ui-spacetimechart/src/index.ts | 4 +- 8 files changed, 241 insertions(+), 66 deletions(-) diff --git a/ui-manchette-with-spacetimechart/src/__tests__/helpers.spec.ts b/ui-manchette-with-spacetimechart/src/__tests__/helpers.spec.ts index b751bd7ac..5845ff601 100644 --- a/ui-manchette-with-spacetimechart/src/__tests__/helpers.spec.ts +++ b/ui-manchette-with-spacetimechart/src/__tests__/helpers.spec.ts @@ -1,7 +1,13 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, test, expect } from 'vitest'; -import { BASE_WAYPOINT_HEIGHT } from '../consts'; -import { computeWaypointsToDisplay, getScales } from '../helpers'; +import { BASE_WAYPOINT_HEIGHT, MAX_ZOOM_Y, MIN_ZOOM_Y } from '../consts'; +import { + computeWaypointsToDisplay, + getScales, + getExtremaScales, + spaceScaleToZoomValue, + zoomValueToSpaceScale, +} from '../helpers'; // Assuming these types from your code @@ -105,3 +111,35 @@ describe('getScales', () => { expect(result[0]).not.toHaveProperty('coefficient'); }); }); + +describe('space scale functions', () => { + const pathLength = 168056000; // mm + const manchettePxHeight = 528; + const heightBetweenFirstLastWaypoints = 489; + + const { minZoomMillimetrePerPx, maxZoomMillimetrePerPx } = getExtremaScales( + manchettePxHeight, + heightBetweenFirstLastWaypoints, + pathLength + ); + expect(minZoomMillimetrePerPx).toBeCloseTo(343672.801); + expect(maxZoomMillimetrePerPx).toBeCloseTo(946.97); + + test('zoomValueToSpaceScale', () => { + expect( + zoomValueToSpaceScale(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, MIN_ZOOM_Y) + ).toBeCloseTo(343672.801); + expect( + zoomValueToSpaceScale(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, MAX_ZOOM_Y) + ).toBeCloseTo(946.97); + }); + + test('spaceScaleToZoomValue', () => { + expect( + spaceScaleToZoomValue(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, 343672.801) + ).toBeCloseTo(MIN_ZOOM_Y); + expect( + spaceScaleToZoomValue(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, 946.97) + ).toBeCloseTo(MAX_ZOOM_Y); + }); +}); diff --git a/ui-manchette-with-spacetimechart/src/consts.ts b/ui-manchette-with-spacetimechart/src/consts.ts index 3052e14e6..b9076c196 100644 --- a/ui-manchette-with-spacetimechart/src/consts.ts +++ b/ui-manchette-with-spacetimechart/src/consts.ts @@ -2,19 +2,16 @@ export const BASE_WAYPOINT_HEIGHT = 32; export const INITIAL_WAYPOINT_LIST_HEIGHT = 521; export const INITIAL_SPACE_TIME_CHART_HEIGHT = INITIAL_WAYPOINT_LIST_HEIGHT + 40; -export const MIN_ZOOM_MS_PER_PX = 600000; +export const MIN_ZOOM_MS_PER_PX = 600_000; export const MAX_ZOOM_MS_PER_PX = 625; -export const DEFAULT_ZOOM_MS_PER_PX = 7500; +export const DEFAULT_ZOOM_MS_PER_PX = 7_500; export const MIN_ZOOM_X = 0; export const MAX_ZOOM_X = 100; -export const MIN_ZOOM_METRE_PER_PX = 10000; -export const MAX_ZOOM_METRE_PER_PX = 10; -export const DEFAULT_ZOOM_METRE_PER_PX = 300; - export const MIN_ZOOM_Y = 1; export const MAX_ZOOM_Y = 10.5; export const ZOOM_Y_DELTA = 0.5; +export const MAX_ZOOM_MANCHETTE_HEIGHT_MILLIMETER = 500_000; export const FOOTER_HEIGHT = 40; // height of the manchette footer export const WAYPOINT_LINE_HEIGHT = 16; diff --git a/ui-manchette-with-spacetimechart/src/helpers.ts b/ui-manchette-with-spacetimechart/src/helpers.ts index c817f278c..5e8c60ac6 100644 --- a/ui-manchette-with-spacetimechart/src/helpers.ts +++ b/ui-manchette-with-spacetimechart/src/helpers.ts @@ -3,16 +3,21 @@ import { clamp } from 'lodash'; import { BASE_WAYPOINT_HEIGHT, - MAX_ZOOM_METRE_PER_PX, MAX_ZOOM_MS_PER_PX, MAX_ZOOM_X, - MIN_ZOOM_METRE_PER_PX, MIN_ZOOM_MS_PER_PX, MIN_ZOOM_X, + MAX_ZOOM_Y, + MIN_ZOOM_Y, + MAX_ZOOM_MANCHETTE_HEIGHT_MILLIMETER, } from './consts'; import { calcTotalDistance, getHeightWithoutLastWaypoint } from './utils'; -type WaypointsOptions = { isProportional: boolean; yZoom: number; height: number }; +type WaypointsOptions = { + isProportional: boolean; + yZoom: number; + height: number; + }; export const filterVisibleElements = ( elements: Waypoint[], @@ -45,20 +50,23 @@ export const filterVisibleElements = ( export const computeWaypointsToDisplay = ( waypoints: Waypoint[], - { height, isProportional, yZoom }: WaypointsOptions + { height, isProportional, yZoom }: WaypointsOptions, + minZoomMillimetrePerPx: number, + maxZoomMillimetrePerPx: number ): InteractiveWaypoint[] => { if (waypoints.length < 2) return []; const totalDistance = calcTotalDistance(waypoints); - const heightWithoutFinalWaypoint = getHeightWithoutLastWaypoint(height); + const manchetteHeight = getHeightWithoutLastWaypoint(height); // display all waypoints in linear mode if (!isProportional) { return waypoints.map((waypoint, index) => { const nextWaypoint = waypoints.at(index + 1); + const waypointHeight = BASE_WAYPOINT_HEIGHT * (nextWaypoint ? yZoom : 1); return { ...waypoint, - styles: { height: `${BASE_WAYPOINT_HEIGHT * (nextWaypoint ? yZoom : 1)}px` }, + styles: { height: `${waypointHeight}px` }, }; }); } @@ -69,30 +77,36 @@ export const computeWaypointsToDisplay = ( const filteredWaypoints = filterVisibleElements( waypoints, totalDistance, - heightWithoutFinalWaypoint, + manchetteHeight, minSpace ); + const spaceScale = zoomValueToSpaceScale(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, yZoom); + return filteredWaypoints.map((waypoint, index) => { const nextWaypoint = filteredWaypoints.at(index + 1); + const waypointHeight = !nextWaypoint + ? BASE_WAYPOINT_HEIGHT + : (nextWaypoint.position - waypoint.position) / spaceScale return { ...waypoint, styles: { - height: !nextWaypoint - ? `${BASE_WAYPOINT_HEIGHT}px` - : `${ - ((nextWaypoint.position - waypoint.position) / totalDistance) * - heightWithoutFinalWaypoint * - yZoom - }px`, + height: `${waypointHeight}px`, }, }; }); }; +/** + * 2 modes for space scales + * km: { coefficient: gives a scale in metre/pixel } (isProportional true) + * linear: { size: height in pixel } (each point distributed evenly along the height of manchette.) + */ export const getScales = ( waypoints: Waypoint[], - { height, isProportional, yZoom }: WaypointsOptions + { isProportional, yZoom }: WaypointsOptions, + minZoomMillimetrePerPx: number, + maxZoomMillimetrePerPx: number ) => { if (waypoints.length < 2) return []; @@ -111,11 +125,8 @@ export const getScales = ( const from = waypoints.at(0)!.position; const to = waypoints.at(-1)!.position; - const totalDistance = calcTotalDistance(waypoints); - const heightWithoutFinalWaypoint = getHeightWithoutLastWaypoint(height); - const scaleCoeff = isProportional - ? { coefficient: totalDistance / heightWithoutFinalWaypoint / yZoom } + ? { coefficient: zoomValueToSpaceScale(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, yZoom) } : { size: BASE_WAYPOINT_HEIGHT * (waypoints.length - 1) * yZoom }; return [ @@ -134,12 +145,48 @@ export 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); -export const zoomValueToSpaceScale = (slider: number) => - MIN_ZOOM_METRE_PER_PX * Math.pow(MAX_ZOOM_METRE_PER_PX / MIN_ZOOM_METRE_PER_PX, slider / 100); +/** + * min zoom is computed with manchette px height between first and last waypoint. + * max zoom just the canvas drawing height (without the x-axis scale section) + */ +export const getExtremaScales = ( + drawingHeightWithoutTopPadding: number, + drawingHeightWithoutBothPadding: number, + pathLengthMillimeter: number +) => { + return { + minZoomMillimetrePerPx: pathLengthMillimeter / drawingHeightWithoutBothPadding, + maxZoomMillimetrePerPx: MAX_ZOOM_MANCHETTE_HEIGHT_MILLIMETER / drawingHeightWithoutTopPadding, + }; +}; + +// export const getScaleFromRectangle = () -export 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); +export const zoomValueToSpaceScale = ( + minZoomMillimetrePerPx: number, + maxZoomMillimetrePerPx: number, + slider: number +) => { + return ( + minZoomMillimetrePerPx * + Math.pow( + maxZoomMillimetrePerPx / minZoomMillimetrePerPx, + (slider - MIN_ZOOM_Y) / (MAX_ZOOM_Y - MIN_ZOOM_Y) + ) + ); +}; + +export const spaceScaleToZoomValue = ( + minZoomMillimetrePerPx: number, + maxZoomMillimetrePerPx: number, + spaceScale: number +) => { + return ( + ((MAX_ZOOM_Y - MIN_ZOOM_Y) * Math.log(spaceScale / minZoomMillimetrePerPx)) / + Math.log(maxZoomMillimetrePerPx / minZoomMillimetrePerPx) + + MIN_ZOOM_Y + ); +}; /** Zoom on X axis and center on the mouse position */ export const zoomX = ( diff --git a/ui-manchette-with-spacetimechart/src/hooks/useManchetteWithSpaceTimeChart.ts b/ui-manchette-with-spacetimechart/src/hooks/useManchetteWithSpaceTimeChart.ts index 7e1025755..710536c56 100644 --- a/ui-manchette-with-spacetimechart/src/hooks/useManchetteWithSpaceTimeChart.ts +++ b/ui-manchette-with-spacetimechart/src/hooks/useManchetteWithSpaceTimeChart.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { ProjectPathTrainResult, Waypoint } from '@osrd-project/ui-manchette/dist/types'; -import type { - SpaceScale, - SpaceTimeChartProps, -} from '@osrd-project/ui-spacetimechart/dist/lib/types'; +import { + type SpaceScale, + type SpaceTimeChartProps, +} from '@osrd-project/ui-spacetimechart'; +import { type Waypoint, type ProjectPathTrainResult } from '@osrd-project/ui-manchette'; import usePaths from './usePaths'; import { @@ -14,19 +14,22 @@ import { DEFAULT_ZOOM_MS_PER_PX, MAX_ZOOM_MS_PER_PX, MIN_ZOOM_MS_PER_PX, - MAX_ZOOM_METRE_PER_PX, - MIN_ZOOM_METRE_PER_PX, + BASE_WAYPOINT_HEIGHT, + FOOTER_HEIGHT, } from '../consts'; import { computeWaypointsToDisplay, getScales, zoomX, zoomValueToTimeScale, + zoomValueToSpaceScale, timeScaleToZoomValue, spaceScaleToZoomValue, + getExtremaScales, } from '../helpers'; import { getDiff } from '../utils/point'; import { clamp } from 'lodash'; +import { calcTotalDistance } from '../utils'; type State = { xZoom: number; @@ -90,11 +93,20 @@ const useManchettesWithSpaceTimeChart = ( isProportional, } = state; - console.log(yZoom); const paths = usePaths(projectPathTrainResult, selectedTrain); + const canvasDrawingHeight = height - FOOTER_HEIGHT; // 521 + const drawingHeightWithoutTopPadding = canvasDrawingHeight - BASE_WAYPOINT_HEIGHT / 2; // 505 + const drawingHeightWithoutBothPadding = canvasDrawingHeight - BASE_WAYPOINT_HEIGHT; // 489 + const totalDistance = calcTotalDistance(waypoints); + + const { minZoomMillimetrePerPx, maxZoomMillimetrePerPx } = getExtremaScales( + drawingHeightWithoutTopPadding, + drawingHeightWithoutBothPadding, + totalDistance + ); const waypointsToDisplay = useMemo( - () => computeWaypointsToDisplay(waypoints, { height, isProportional, yZoom }), + () => computeWaypointsToDisplay(waypoints, { height, isProportional, yZoom }, minZoomMillimetrePerPx, maxZoomMillimetrePerPx), [waypoints, height, isProportional, yZoom] ); @@ -109,6 +121,21 @@ const useManchettesWithSpaceTimeChart = ( [waypointsToDisplay] ); + const computedScales = useMemo( + () => getScales(simplifiedWaypoints, { height, isProportional, yZoom }, minZoomMillimetrePerPx, maxZoomMillimetrePerPx), + [simplifiedWaypoints, height, isProportional, yZoom] + ); + + // console.log(computedScales, minZoomMillimetrePerPx, maxZoomMillimetrePerPx); + + console.log( + '\n', + `x: zoom=${xZoom}, ${zoomValueToTimeScale(xZoom)} ms/px`, + '\n', + `y: zoom=${yZoom.toFixed(0)}, ${zoomValueToSpaceScale(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, yZoom).toFixed(0)} mm/px + ${(zoomValueToSpaceScale(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, yZoom) * canvasDrawingHeight).toFixed(0)} mm` + ); + const handleRectangleZoom = useCallback( ({ scales: { chosenTimeScale, chosenSpaceScale }, @@ -121,23 +148,31 @@ const useManchettesWithSpaceTimeChart = ( 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 newSpaceScale = clamp( + chosenSpaceScale, + maxZoomMillimetrePerPx, + minZoomMillimetrePerPx + ); const timeZoomValue = timeScaleToZoomValue(newTimeScale); - const spaceZoomValue = spaceScaleToZoomValue(newSpaceScale); + const spaceZoomValue = spaceScaleToZoomValue( + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx, + newSpaceScale + ); 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; + const newYOffset = Math.abs(spaceOrigin - topRectSide) / newSpaceScale; return { ...prev, - timeZoomValue: timeZoomValue, - spaceZoomValue: spaceZoomValue, + xZoom: timeZoomValue, + yZoom: spaceZoomValue, xOffset: newXOffset, yOffset: newYOffset, + scrollTo: newYOffset, ...overrideState, }; }); @@ -145,6 +180,21 @@ const useManchettesWithSpaceTimeChart = ( [timeOrigin, spaceOrigin] ); + useEffect(() => { + if (rect && !zoomMode && spaceTimeChartRef?.current) { + const { timeStart, timeEnd, spaceStart, spaceEnd } = 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 = timeRange / spaceTimeChartRef.current.clientWidth; + const chosenSpaceScale = spaceRange / drawingHeightWithoutTopPadding; + console.log(1, spaceRange, drawingHeightWithoutTopPadding, chosenSpaceScale); + handleRectangleZoom({ + scales: { chosenTimeScale, chosenSpaceScale }, + overrideState: { rect: null }, + }); + } + }, [state.rect, state.zoomMode, handleRectangleZoom]); + const zoomYIn = useCallback(() => { if (yZoom < MAX_ZOOM_Y) { const newYZoom = yZoom + ZOOM_Y_DELTA; @@ -230,15 +280,9 @@ const useManchettesWithSpaceTimeChart = ( setState((prev) => ({ ...prev, isProportional: !prev.isProportional })); }, []); - const computedScales = useMemo( - () => getScales(simplifiedWaypoints, { height, isProportional, yZoom }), - [simplifiedWaypoints, height, isProportional, yZoom] - ); - const toggleZoomMode = useCallback(() => { setState((prev) => ({ ...prev, zoomMode: !prev.zoomMode })); }, []); - // console.log('computedScales', computedScales); const manchetteProps = useMemo( () => ({ @@ -344,8 +388,18 @@ const useManchettesWithSpaceTimeChart = ( xZoom, toggleZoomMode, zoomMode, + rect, }), - [manchetteProps, spaceTimeChartProps, handleScroll, handleXZoom, xZoom] + [ + manchetteProps, + spaceTimeChartProps, + handleScroll, + handleXZoom, + xZoom, + toggleMode, + zoomMode, + rect, + ] ); }; diff --git a/ui-manchette-with-spacetimechart/src/stories/base.stories.tsx b/ui-manchette-with-spacetimechart/src/stories/base.stories.tsx index 92e168cee..e4042a9cd 100644 --- a/ui-manchette-with-spacetimechart/src/stories/base.stories.tsx +++ b/ui-manchette-with-spacetimechart/src/stories/base.stories.tsx @@ -52,8 +52,6 @@ const ManchetteWithSpaceTimeWrapper = ({ > +p.departureTime))} {...spaceTimeChartProps} > {spaceTimeChartProps.paths.map((path) => ( diff --git a/ui-manchette-with-spacetimechart/src/stories/rectangle-zoom.stories.tsx b/ui-manchette-with-spacetimechart/src/stories/rectangle-zoom.stories.tsx index a432eb97d..55a74b13e 100644 --- a/ui-manchette-with-spacetimechart/src/stories/rectangle-zoom.stories.tsx +++ b/ui-manchette-with-spacetimechart/src/stories/rectangle-zoom.stories.tsx @@ -1,7 +1,12 @@ import React, { useRef } from 'react'; - +import cx from 'classnames'; import Manchette, { type ProjectPathTrainResult, type Waypoint } from '@osrd-project/ui-manchette'; -import { PathLayer, SpaceTimeChart, RectangleZoom } from '@osrd-project/ui-spacetimechart'; +import { + PathLayer, + SpaceTimeChart, + RectangleZoom, + MouseTracker, +} from '@osrd-project/ui-spacetimechart'; import type { Meta } from '@storybook/react'; import '@osrd-project/ui-core/dist/theme.css'; @@ -27,13 +32,15 @@ const ManchetteWithSpaceTimeWrapper = ({ selectedTrain, }: ManchetteWithSpaceTimeWrapperProps) => { const manchetteWithSpaceTimeChartRef = useRef(null); - - const { manchetteProps, spaceTimeChartProps, handleScroll, toggleZoomMode, zoomMode } = + const spaceTimeChartRef = useRef(null); + const { manchetteProps, spaceTimeChartProps, handleScroll, toggleZoomMode, zoomMode, rect } = useManchettesWithSpaceTimeChart( waypoints, projectPathTrainResult, manchetteWithSpaceTimeChartRef, - selectedTrain + selectedTrain, + DEFAULT_HEIGHT, + spaceTimeChartRef ); return ( @@ -51,18 +58,24 @@ const ManchetteWithSpaceTimeWrapper = ({
-
- + {spaceTimeChartProps.paths.map((path) => ( ))} {spaceTimeChartProps.rect && } +
diff --git a/ui-manchette-with-spacetimechart/src/styles/stories/rectangle-zoom.css b/ui-manchette-with-spacetimechart/src/styles/stories/rectangle-zoom.css index f01931233..7fc6709f7 100644 --- a/ui-manchette-with-spacetimechart/src/styles/stories/rectangle-zoom.css +++ b/ui-manchette-with-spacetimechart/src/styles/stories/rectangle-zoom.css @@ -1,4 +1,8 @@ .space-time-chart-container { + .space-time-chart-zoom-mode { + cursor: crosshair; + } + .toolbar { position: absolute; right: 7px; @@ -22,5 +26,27 @@ button:last-child { border-radius: 5px; } + + .zoom-button { + .icon { + display: inline-block; + } + + &:hover { + background-color: theme('colors.primary.5'); + } + } + + .zoom-button-clicked { + background-color: theme('colors.primary.90'); + + .icon { + color: theme('colors.white.100'); + } + + &:hover { + background-color: theme('colors.primary.90'); + } + } } } diff --git a/ui-spacetimechart/src/index.ts b/ui-spacetimechart/src/index.ts index 90784d123..e38b76704 100644 --- a/ui-spacetimechart/src/index.ts +++ b/ui-spacetimechart/src/index.ts @@ -1,6 +1,6 @@ import './styles/main.css'; -export type { HoveredItem, SpaceTimeChartProps } from './lib/types'; +export type { SpaceScale, HoveredItem, SpaceTimeChartProps } from './lib/types'; export * from './components/SpaceTimeChart'; export * from './components/PathLayer'; @@ -10,3 +10,5 @@ export * from './components/OccupancyBlockLayer'; export * from './components/WorkScheduleLayer'; export * from './components/PatternRect'; export * from './components/RectangleZoom'; +export * from './components/TimeCaptions'; +export * from './stories/lib/components';