From bf420d85b84a279aef89330b8a7b57d61bf83c43 Mon Sep 17 00:00:00 2001 From: Valentin Chanas Date: Mon, 24 Feb 2025 23:56:54 +0100 Subject: [PATCH] ui-manchette-with-spacetime-chart: story: zoom on manchette + spacetime Signed-off-by: Valentin Chanas --- package-lock.json | 3 +- .../__tests__/helpers.spec.ts | 44 ++- .../useManchetteWithSpaceTimeChart/consts.ts | 5 +- .../useManchetteWithSpaceTimeChart/helpers.ts | 82 ++++- .../hooks/useManchetteWithSpaceTimeChart.ts | 306 +++++++++++++++--- .../stories/base.stories.tsx | 7 +- .../stories/rectangle-zoom.stories.tsx | 117 +++++++ .../styles/stories/rectangle-zoom.css | 62 ++++ ui-charts/src/spaceTimeChart/index.ts | 3 + 9 files changed, 561 insertions(+), 68 deletions(-) create mode 100644 ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/rectangle-zoom.stories.tsx create mode 100644 ui-charts/src/manchette/useManchetteWithSpaceTimeChart/styles/stories/rectangle-zoom.css diff --git a/package-lock.json b/package-lock.json index f6bcf0642..c92b96b61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19076,8 +19076,7 @@ "@types/d3-selection": "^3.0.0", "@types/d3-zoom": "^3.0.0", "@types/file-saver": "^2.0.7", - "file-saver": "^2.0.5", - "vitest": "^3.0.2" + "file-saver": "^2.0.5" }, "peerDependencies": { "classnames": ">=2.5", diff --git a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/__tests__/helpers.spec.ts b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/__tests__/helpers.spec.ts index b751bd7ac..5845ff601 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/__tests__/helpers.spec.ts +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/__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-charts/src/manchette/useManchetteWithSpaceTimeChart/consts.ts b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/consts.ts index c85b1bcb1..b9076c196 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/consts.ts +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/consts.ts @@ -2,15 +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_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-charts/src/manchette/useManchetteWithSpaceTimeChart/helpers.ts b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/helpers.ts index 3e1e92925..e3b473a2c 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/helpers.ts +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/helpers.ts @@ -6,11 +6,18 @@ import { MAX_ZOOM_X, 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'; import type { InteractiveWaypoint, Waypoint } from '../Manchette'; -type WaypointsOptions = { isProportional: boolean; yZoom: number; height: number }; +type WaypointsOptions = { + isProportional: boolean; + yZoom: number; + height: number; +}; export const filterVisibleElements = ( elements: Waypoint[], @@ -43,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` }, }; }); } @@ -67,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 (isProportional): { coefficient: gives a scale in metre/pixel } + * 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 []; @@ -109,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 [ @@ -132,6 +145,41 @@ 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); +/** + * 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 +) => ({ + minZoomMillimetrePerPx: pathLengthMillimeter / drawingHeightWithoutBothPadding, + maxZoomMillimetrePerPx: MAX_ZOOM_MANCHETTE_HEIGHT_MILLIMETER / drawingHeightWithoutTopPadding, +}); + +// export const getScaleFromRectangle = () + +export const zoomValueToSpaceScale = ( + minZoomMillimetrePerPx: number, + maxZoomMillimetrePerPx: number, + slider: number +) => + minZoomMillimetrePerPx * + Math.pow( + maxZoomMillimetrePerPx / minZoomMillimetrePerPx, + (slider - MIN_ZOOM_Y) / (MAX_ZOOM_Y - MIN_ZOOM_Y) + ); + +export const spaceScaleToZoomValue = ( + minZoomMillimetrePerPx: number, + maxZoomMillimetrePerPx: number, + spaceScale: number +) => + ((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 = ( currentZoom: number, diff --git a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts index e4c0c0444..242c69680 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts @@ -1,27 +1,58 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { clamp } from 'lodash'; + import usePaths from './usePaths'; import type { SpaceScale, SpaceTimeChartProps } from '../../../spaceTimeChart'; import type { ProjectPathTrainResult, Waypoint } from '../../Manchette'; -import { MAX_ZOOM_Y, MIN_ZOOM_Y, ZOOM_Y_DELTA, DEFAULT_ZOOM_MS_PER_PX } from '../consts'; +import { + MAX_ZOOM_Y, + MIN_ZOOM_Y, + ZOOM_Y_DELTA, + DEFAULT_ZOOM_MS_PER_PX, + MAX_ZOOM_MS_PER_PX, + MIN_ZOOM_MS_PER_PX, + BASE_WAYPOINT_HEIGHT, + FOOTER_HEIGHT, + WAYPOINT_LINE_HEIGHT, +} from '../consts'; import { computeWaypointsToDisplay, getScales, zoomX, zoomValueToTimeScale, timeScaleToZoomValue, + spaceScaleToZoomValue, + getExtremaScales, } from '../helpers'; +import { calcTotalDistance } from '../utils'; import { getDiff } from '../utils/point'; type State = { xZoom: number; yZoom: number; + timeOrigin: number; + spaceOrigin: number; + /** current x PIXEL offset from x origin */ xOffset: number; - /** the current y-scroll of the view. always updates */ + /** current y PIXEL offset from y origin (the current y-scroll of the view. always updates) */ yOffset: number; /** only update after a zoom. used to update back the view scroll value */ scrollTo: number | null; panning: { initialOffset: { x: number; y: number } } | null; + zoomMode: boolean; + rect: { + timeStart: Date; + timeEnd: Date; + spaceStart: number; // mm + spaceEnd: number; // mm + } | null; + pixelRect: { + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; + } | null; isProportional: boolean; waypointsChart: Waypoint[]; scales: SpaceScale[]; @@ -39,21 +70,55 @@ const useManchettesWithSpaceTimeChart = ( const [state, setState] = useState({ xZoom: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX), yZoom: 1, + timeOrigin: Math.min(...projectPathTrainResult.map((p) => +p.departureTime)), + spaceOrigin: 0, xOffset: 0, yOffset: 0, scrollTo: null, panning: null, + zoomMode: false, + rect: null, + pixelRect: null, isProportional: true, waypointsChart: [], scales: [], }); - const { xZoom, yZoom, xOffset, yOffset, scrollTo, panning, isProportional } = state; + const { + xZoom, + yZoom, + timeOrigin, + spaceOrigin, + xOffset, + yOffset, + scrollTo, + panning, + zoomMode, + rect, + pixelRect, + isProportional, + } = state; 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] ); @@ -68,9 +133,116 @@ const useManchettesWithSpaceTimeChart = ( [waypointsToDisplay] ); + const computedScales = useMemo( + () => + getScales( + simplifiedWaypoints, + { height, isProportional, yZoom }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ), + [simplifiedWaypoints, height, isProportional, yZoom] + ); + + // 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 }, + 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 timeZoomValue = timeScaleToZoomValue(newTimeScale); + const leftRectSide = Math.min(Number(prev.rect.timeStart), Number(prev.rect.timeEnd)); + const newXOffset = (timeOrigin - leftRectSide) / newTimeScale; + + let newYZoom = yZoom; + let newYOffset = yOffset; + if (chosenSpaceScale) { + const newSpaceScale = clamp( + chosenSpaceScale, + maxZoomMillimetrePerPx, + minZoomMillimetrePerPx + ); + newYZoom = spaceScaleToZoomValue( + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx, + newSpaceScale + ); + const topRectSide = Math.min(prev.rect.spaceStart, prev.rect.spaceEnd); + newYOffset = Math.abs(spaceOrigin - topRectSide) / newSpaceScale; + } + + return { + ...prev, + xZoom: timeZoomValue, + yZoom: newYZoom, + xOffset: newXOffset, + yOffset: newYOffset, + scrollTo: newYOffset, + ...overrideState, + }; + }); + }, + [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; + if (isProportional) { + const chosenSpaceScale = spaceRange / drawingHeightWithoutTopPadding; + handleRectangleZoom({ + scales: { chosenTimeScale, chosenSpaceScale }, + overrideState: { rect: null }, + }); + } else if (pixelRect) { + // const manchetteFullHeight = computedScales.reduce((acc, s) => acc + (s.size || 0), 0) * yZoom; + const currentStopHeight = BASE_WAYPOINT_HEIGHT * yZoom; + const { yStart, yEnd } = pixelRect; + const numberOfStops = Math.abs(yEnd - yStart) / currentStopHeight; + const newStopHeight = drawingHeightWithoutTopPadding / numberOfStops; + const newYZoom = newStopHeight / BASE_WAYPOINT_HEIGHT; + const rectTop = yOffset + Math.min(yStart, yEnd) - WAYPOINT_LINE_HEIGHT; + const numberOfStopsBeforeRectTop = rectTop / currentStopHeight; + const newYOffset = numberOfStopsBeforeRectTop * newStopHeight; + + handleRectangleZoom({ + scales: { + chosenTimeScale, + }, + overrideState: { + rect: null, + pixelRect: null, + yZoom: newYZoom, + yOffset: newYOffset, + scrollTo: newYOffset, + }, + }); + } + } + }, [state.rect, state.zoomMode, handleRectangleZoom]); + const zoomYIn = useCallback(() => { - if (yZoom < MAX_ZOOM_Y) { - const newYZoom = yZoom + ZOOM_Y_DELTA; + const newYZoom = Math.min(yZoom + ZOOM_Y_DELTA, MAX_ZOOM_Y); + if (newYZoom !== yZoom) { const newYOffset = yOffset * (newYZoom / yZoom); setState((prev) => ({ @@ -83,8 +255,8 @@ const useManchettesWithSpaceTimeChart = ( }, [yZoom, yOffset]); const zoomYOut = useCallback(() => { - if (yZoom > MIN_ZOOM_Y) { - const newYZoom = yZoom - ZOOM_Y_DELTA; + const newYZoom = Math.max(MIN_ZOOM_Y, yZoom - ZOOM_Y_DELTA); + if (newYZoom !== yZoom) { const newYOffset = yOffset * (newYZoom / yZoom); setState((prev) => ({ ...prev, @@ -95,6 +267,16 @@ const useManchettesWithSpaceTimeChart = ( } }, [yZoom, yOffset]); + const handleXZoom = useCallback( + (newXZoom: number, xPosition = (spaceTimeChartRef?.current?.offsetWidth || 0) / 2) => { + setState((prev) => ({ + ...prev, + ...zoomX(prev.xZoom, prev.xOffset, newXZoom, xPosition), + })); + }, + [spaceTimeChartRef] + ); + useEffect(() => { if (scrollTo !== null && manchetteWithSpaceTimeChartContainer.current) { manchetteWithSpaceTimeChartContainer.current.scrollTo({ @@ -143,10 +325,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 })); + }, []); const manchetteProps = useMemo( () => ({ @@ -162,16 +343,6 @@ const useManchettesWithSpaceTimeChart = ( [waypointsToDisplay, zoomYIn, zoomYOut, resetZoom, toggleMode, yZoom, isProportional, yOffset] ); - const handleXZoom = useCallback( - (newXZoom: number, xPosition = (spaceTimeChartRef?.current?.offsetWidth || 0) / 2) => { - setState((prev) => ({ - ...prev, - ...zoomX(prev.xZoom, prev.xOffset, newXZoom, xPosition), - })); - }, - [spaceTimeChartRef] - ); - const spaceTimeChartProps = useMemo( () => ({ operationalPoints: simplifiedWaypoints, @@ -180,24 +351,71 @@ const useManchettesWithSpaceTimeChart = ( paths, xOffset, yOffset: -yOffset + 14, + timeOrigin, + spaceOrigin, + rect, onZoom: ({ delta, position }: Parameters>[0]) => { if (isShiftPressed) { handleXZoom(xZoom + delta, position.x); } }, - onPan: (payload: { - initialPosition: { x: number; y: number }; - position: { x: number; y: number }; - isPanning: boolean; - }) => { - const diff = getDiff(payload.initialPosition, payload.position); - const newState = { ...state }; - - if (!payload.isPanning) { - newState.panning = null; - } else if (!panning) { - newState.panning = { initialOffset: { x: xOffset, y: yOffset } }; - } else { + onPan: (payload: Parameters>[0]) => { + const { + initialData, + data, + initialPosition, + position, + isPanning, + context: { width, height, getData }, + } = payload; + const diff = getDiff(initialPosition, position); + setState((prev) => { + if (!isPanning) { + return { + ...prev, + panning: null, + zoomMode: false, + }; + } + + if (state.zoomMode) { + const minPoint = getData({ x: 0, y: 0 }); + const maxPoint = getData({ x: width, y: height - FOOTER_HEIGHT }); + const timeStart = clamp(initialData.time, minPoint.time, maxPoint.time); + const timeEnd = clamp(data.time, minPoint.time, maxPoint.time); + const spaceStart = clamp(initialData.position, minPoint.position, maxPoint.position); + const spaceEnd = clamp(data.position, minPoint.position, maxPoint.position); + const newRect = { + timeStart: new Date(timeStart), + timeEnd: new Date(timeEnd), + spaceStart, + spaceEnd, + }; + + let newPixelRect = null; + if (!isProportional) { + const xStart = clamp(initialPosition.x, 0, width); + const xEnd = clamp(position.x, 0, width); + const yStart = clamp(initialPosition.y, 0, height - FOOTER_HEIGHT); + const yEnd = clamp(position.y, 0, height - FOOTER_HEIGHT); + newPixelRect = { xStart, xEnd, yStart, yEnd }; + } + + return { + ...prev, + rect: newRect, + pixelRect: newPixelRect, + }; + } + + if (!panning) { + return { + ...prev, + panning: { initialOffset: { x: xOffset, y: yOffset } }, + }; + } + + const newState = { ...prev }; const { initialOffset } = panning; newState.xOffset = initialOffset.x + diff.x; @@ -211,8 +429,8 @@ const useManchettesWithSpaceTimeChart = ( newState.yOffset = newYPos; manchetteWithSpaceTimeChartContainer.current.scrollTop = newYPos; } - } - setState(newState); + return newState; + }); }, }), [ @@ -237,8 +455,20 @@ const useManchettesWithSpaceTimeChart = ( handleScroll, handleXZoom, xZoom, + toggleZoomMode, + zoomMode, + rect, }), - [manchetteProps, spaceTimeChartProps, handleScroll, handleXZoom, xZoom] + [ + manchetteProps, + spaceTimeChartProps, + handleScroll, + handleXZoom, + xZoom, + toggleMode, + zoomMode, + rect, + ] ); }; diff --git a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base.stories.tsx b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base.stories.tsx index ffdb2f8d8..ecb1185d9 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base.stories.tsx +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base.stories.tsx @@ -49,12 +49,7 @@ const ManchetteWithSpaceTimeWrapper = ({ className="space-time-chart-container w-full sticky" style={{ bottom: 0, left: 0, top: 2, height: `${DEFAULT_HEIGHT - 6}px` }} > - +p.departureTime))} - {...spaceTimeChartProps} - > + {spaceTimeChartProps.paths.map((path) => ( ))} diff --git a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/rectangle-zoom.stories.tsx b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/rectangle-zoom.stories.tsx new file mode 100644 index 000000000..251fd5a27 --- /dev/null +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/rectangle-zoom.stories.tsx @@ -0,0 +1,117 @@ +import React, { useRef } from 'react'; + +import type { Meta } from '@storybook/react'; +import cx from 'classnames'; + +import { PathLayer, SpaceTimeChart, ZoomRect, MouseTracker } from '../../../spaceTimeChart'; +import Manchette, { type ProjectPathTrainResult, type Waypoint } from '../../Manchette'; + +import '@osrd-project/ui-core/dist/theme.css'; +import '@osrd-project/ui-charts/dist/theme.css'; +import '../styles/stories/rectangle-zoom.css'; + +import { SAMPLE_WAYPOINTS, SAMPLE_PATHS_DATA } from '../assets/sampleData'; +import useManchettesWithSpaceTimeChart from '../hooks/useManchetteWithSpaceTimeChart'; + +import { ZoomIn } from '@osrd-project/ui-icons'; +import { Slider } from '@osrd-project/ui-core'; + +type ManchetteWithSpaceTimeWrapperProps = { + waypoints: Waypoint[]; + projectPathTrainResult: ProjectPathTrainResult[]; + selectedTrain: number; +}; + +const DEFAULT_HEIGHT = 561; + +const ManchetteWithSpaceTimeWrapper = ({ + waypoints, + projectPathTrainResult, + selectedTrain, +}: ManchetteWithSpaceTimeWrapperProps) => { + const manchetteWithSpaceTimeChartRef = useRef(null); + const spaceTimeChartRef = useRef(null); + const { + manchetteProps, + spaceTimeChartProps, + handleScroll, + toggleZoomMode, + zoomMode, + xZoom, + handleXZoom, + } = useManchettesWithSpaceTimeChart( + waypoints, + projectPathTrainResult, + manchetteWithSpaceTimeChartRef, + selectedTrain, + DEFAULT_HEIGHT, + spaceTimeChartRef + ); + + return ( +
+
+
+ +
+
+ +
+ + {spaceTimeChartProps.paths.map((path) => ( + + ))} + {spaceTimeChartProps.rect && } + + +
+
+ { + handleXZoom(Number(e.target.value)); + }} + /> +
+ ); +}; + +const meta: Meta = { + title: 'Manchette with SpaceTimeChart/Zoom rectangle', + component: ManchetteWithSpaceTimeWrapper, +}; + +export default meta; + +export const Default = { + args: { + waypoints: SAMPLE_WAYPOINTS, + projectPathTrainResult: SAMPLE_PATHS_DATA, + selectedTrain: 1, + }, +}; diff --git a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/styles/stories/rectangle-zoom.css b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/styles/stories/rectangle-zoom.css new file mode 100644 index 000000000..a3bd3b334 --- /dev/null +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/styles/stories/rectangle-zoom.css @@ -0,0 +1,62 @@ +.manchette-space-time-chart-wrapper { + .space-time-chart-container { + .space-time-chart-zoom-mode { + cursor: crosshair; + user-select: none; + } + + .toolbar { + position: absolute; + right: 7px; + top: 7px; + z-index: 10; + border: 1px solid theme('colors.black.10'); + border-radius: 5px; + + button { + outline: none; + background: theme('colors.white.100'); + color: theme('colors.black.100'); + padding: 5px 7px 0; + } + + button:not(:last-child) { + border-radius: 5px 0 0 5px; + border-right: 1px solid theme('colors.black.10'); + } + + 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'); + } + } + } + } + + .space-time-h-slider-container { + position: absolute; + right: 24px; + bottom: -21px; + z-index: 10; + } +} diff --git a/ui-charts/src/spaceTimeChart/index.ts b/ui-charts/src/spaceTimeChart/index.ts index db6bda0c3..0b2e51841 100644 --- a/ui-charts/src/spaceTimeChart/index.ts +++ b/ui-charts/src/spaceTimeChart/index.ts @@ -18,3 +18,6 @@ export * from './components/ConflictTooltip'; export * from './components/OccupancyBlockLayer'; export * from './components/WorkScheduleLayer'; export * from './components/PatternRect'; +export * from './components/ZoomRect'; +export * from './components/TimeCaptions'; +export * from './stories/lib/components';