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..734e64d4f 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/__tests__/helpers.spec.ts +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/__tests__/helpers.spec.ts @@ -1,65 +1,115 @@ -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 // Mock data for the tests const mockedWaypoints = [ { position: 0, id: 'waypoint-1' }, - { position: 10, id: 'waypoint-2' }, - { position: 20, id: 'waypoint-3' }, + { position: 100_000_000, id: 'waypoint-2' }, + { position: 200_000_000, id: 'waypoint-3' }, ]; +const minZoomMillimetrePerPx = 500_000; +const maxZoomMillimetrePerPx = 1_000; describe('computeWaypointsToDisplay', () => { it('should ensure that a empty array is returned when there is only 1 waypoint', () => { - const result = computeWaypointsToDisplay([mockedWaypoints[0]], { - height: 500, - isProportional: true, - yZoom: 1, - }); + const result = computeWaypointsToDisplay( + [mockedWaypoints[0]], + { + height: 500, + isProportional: true, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); expect(result.length).toBe(0); }); it('should display all points for non-proportional display', () => { - const result = computeWaypointsToDisplay(mockedWaypoints, { - height: 100, - isProportional: false, - yZoom: 1, - }); + const result = computeWaypointsToDisplay( + mockedWaypoints, + { + height: 100, + isProportional: false, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); expect(result).toHaveLength(mockedWaypoints.length); expect(result[0].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`); expect(result[1].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`); }); it('should correctly filter waypoints', () => { - const result = computeWaypointsToDisplay(mockedWaypoints, { - height: 100, - isProportional: true, - yZoom: 1, - }); + const result = computeWaypointsToDisplay( + mockedWaypoints, + { + height: 100, + isProportional: true, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); expect(result).toHaveLength(2); }); - it('should return correct heights for proportional display', () => { - const result = computeWaypointsToDisplay(mockedWaypoints, { - height: 500, - isProportional: true, - yZoom: 2, - }); + it('should return correct heights for proportional display, zoom 1', () => { + const result = computeWaypointsToDisplay( + mockedWaypoints, + { + height: 500, + isProportional: true, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); expect(result).toHaveLength(mockedWaypoints.length); - expect(result[0].styles?.height).toBe(`428px`); - expect(result[1].styles?.height).toBe(`428px`); + expect(result[0].styles?.height).toBe(`200px`); + expect(result[1].styles?.height).toBe(`200px`); + expect(result[2].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`); + }); + + it('should return correct heights for proportional display, zoom 2', () => { + const result = computeWaypointsToDisplay( + mockedWaypoints, + { + height: 500, + isProportional: true, + yZoom: 2, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); + expect(result).toHaveLength(mockedWaypoints.length); + expect(result[0].styles?.height).toBe(`385px`); + expect(result[1].styles?.height).toBe(`385px`); expect(result[2].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`); }); it('should ensure the last point is always displayed', () => { - const result = computeWaypointsToDisplay(mockedWaypoints, { - height: 100, - isProportional: true, - yZoom: 1, - }); + const result = computeWaypointsToDisplay( + mockedWaypoints, + { + height: 100, + isProportional: true, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); expect(result.some((waypoint) => waypoint.id === 'waypoint-3')).toBe(true); }); }); @@ -74,34 +124,82 @@ describe('getScales', () => { it('Should ensure that a empty array is return when there is only 1 waypoint', () => { const ops = [mockOpsWithPosition[0]]; - const result = getScales(ops, { - height: 500, - isProportional: true, - yZoom: 1, - }); + const result = getScales( + ops, + { + height: 500, + isProportional: true, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); expect(result).toHaveLength(0); }); it('should return correct scale coefficients for proportional display', () => { - const result = getScales(mockOpsWithPosition, { - height: 500, - isProportional: true, - yZoom: 1, - }); - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('coefficient'); + const result = getScales( + mockOpsWithPosition, + { + height: 500, + isProportional: true, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); + expect(result).toEqual([{ from: 0, to: 200000000, coefficient: 500000 }]); expect(result[0].size).not.toBeDefined(); }); it('should return correct size for non-proportional display', () => { - const result = getScales(mockOpsWithPosition, { - height: 500, - isProportional: false, - yZoom: 1, - }); + const result = getScales( + mockOpsWithPosition, + { + height: 500, + isProportional: false, + yZoom: 1, + }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ); - expect(result).toHaveLength(2); - expect(result[0].size).toBeDefined(); + expect(result).toEqual([ + { from: 0, to: 100000000, size: 32 }, + { from: 100000000, to: 200000000, size: 32 }, + ]); 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/components/ManchetteWithSpaceTimeChart.tsx b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/components/ManchetteWithSpaceTimeChart.tsx index ad45dd7b6..be786ed44 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/components/ManchetteWithSpaceTimeChart.tsx +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/components/ManchetteWithSpaceTimeChart.tsx @@ -69,8 +69,6 @@ const ManchetteWithSpaceTimeChart = ({ > +p.departureTime))} {...spaceTimeChartProps} {...additionalSpaceTimeChartProps} > 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..0a259b291 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/helpers.ts +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/helpers.ts @@ -6,11 +6,77 @@ 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 }; +export const zoomValueToTimeScale = (slider: number) => + MIN_ZOOM_MS_PER_PX * Math.pow(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX, slider / 100); + +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, + currentOffset: number, + newZoom: number, + position: number +) => { + const boundedZoom = clamp(newZoom, MIN_ZOOM_X, MAX_ZOOM_X); + const oldTimeScale = zoomValueToTimeScale(currentZoom); + const newTimeScale = zoomValueToTimeScale(boundedZoom); + const newOffset = position - ((position - currentOffset) * oldTimeScale) / newTimeScale; + return { + xZoom: boundedZoom, + xOffset: newOffset, + }; +}; + +type WaypointsOptions = { + isProportional: boolean; + yZoom: number; + height: number; +}; export const filterVisibleElements = ( elements: Waypoint[], @@ -43,20 +109,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 +136,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: `${Math.round(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 +184,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 [ @@ -124,27 +196,3 @@ export const getScales = ( }, ]; }; - -export const zoomValueToTimeScale = (slider: number) => - MIN_ZOOM_MS_PER_PX * Math.pow(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX, slider / 100); - -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); - -/** Zoom on X axis and center on the mouse position */ -export const zoomX = ( - currentZoom: number, - currentOffset: number, - newZoom: number, - position: number -) => { - const boundedZoom = clamp(newZoom, MIN_ZOOM_X, MAX_ZOOM_X); - const oldTimeScale = zoomValueToTimeScale(currentZoom); - const newTimeScale = zoomValueToTimeScale(boundedZoom); - const newOffset = position - ((position - currentOffset) * oldTimeScale) / newTimeScale; - return { - xZoom: boundedZoom, - xOffset: newOffset, - }; -}; diff --git a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts index e4c0c0444..3a531248d 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/hooks/useManchetteWithSpaceTimeChart.ts @@ -1,27 +1,59 @@ 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, + zoomValueToSpaceScale, } 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,22 +71,56 @@ 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 }), - [waypoints, height, isProportional, yZoom] + () => + computeWaypointsToDisplay( + waypoints, + { height, isProportional, yZoom }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ), + [waypoints, height, isProportional, yZoom, minZoomMillimetrePerPx, maxZoomMillimetrePerPx] ); const simplifiedWaypoints = useMemo( @@ -68,9 +134,126 @@ const useManchettesWithSpaceTimeChart = ( [waypointsToDisplay] ); + const computedScales = useMemo( + () => + getScales( + simplifiedWaypoints, + { height, isProportional, yZoom }, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx + ), + [ + simplifiedWaypoints, + height, + isProportional, + yZoom, + minZoomMillimetrePerPx, + maxZoomMillimetrePerPx, + ] + ); + + 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, minZoomMillimetrePerPx, maxZoomMillimetrePerPx, yOffset, yZoom] + ); + + 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 mm + + const chosenTimeScale = timeRange / spaceTimeChartRef.current.clientWidth; + if (isProportional) { + const chosenSpaceScale = spaceRange / drawingHeightWithoutTopPadding; + handleRectangleZoom({ + scales: { chosenTimeScale, chosenSpaceScale }, + overrideState: { rect: null }, + }); + } else if (pixelRect) { + 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, + drawingHeightWithoutTopPadding, + isProportional, + pixelRect, + rect, + spaceTimeChartRef, + yOffset, + yZoom, + zoomMode, + ]); + 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 +266,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 +278,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 +336,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 +354,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 +362,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, 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: canvasDrawingHeight }); + 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, canvasDrawingHeight); + const yEnd = clamp(position.y, 0, canvasDrawingHeight); + 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 +440,8 @@ const useManchettesWithSpaceTimeChart = ( newState.yOffset = newYPos; manchetteWithSpaceTimeChartContainer.current.scrollTop = newYPos; } - } - setState(newState); + return newState; + }); }, }), [ @@ -227,9 +456,19 @@ const useManchettesWithSpaceTimeChart = ( yOffset, manchetteWithSpaceTimeChartContainer, handleXZoom, + isProportional, + rect, + spaceOrigin, + timeOrigin, + canvasDrawingHeight, ] ); + const timeScale = useMemo(() => zoomValueToTimeScale(xZoom), [xZoom]); + const spaceScale = useMemo( + () => zoomValueToSpaceScale(minZoomMillimetrePerPx, maxZoomMillimetrePerPx, yZoom), + [yZoom, minZoomMillimetrePerPx, maxZoomMillimetrePerPx] + ); return useMemo( () => ({ manchetteProps, @@ -237,8 +476,24 @@ const useManchettesWithSpaceTimeChart = ( handleScroll, handleXZoom, xZoom, + toggleZoomMode, + zoomMode, + rect, + timeScale, + spaceScale, }), - [manchetteProps, spaceTimeChartProps, handleScroll, handleXZoom, xZoom] + [ + manchetteProps, + spaceTimeChartProps, + handleScroll, + handleXZoom, + xZoom, + zoomMode, + rect, + spaceScale, + timeScale, + toggleZoomMode, + ] ); }; diff --git a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base-with-waypoint-menu.stories.tsx b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base-with-waypoint-menu.stories.tsx index 62fa8cab0..467d89ccf 100644 --- a/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base-with-waypoint-menu.stories.tsx +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/base-with-waypoint-menu.stories.tsx @@ -137,8 +137,6 @@ const ManchetteWithSpaceTimeWrapper = ({ > +p.departureTime))} {...spaceTimeChartProps} onPan={activeWaypointId ? undefined : spaceTimeChartProps.onPan} > 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..f9e2631c4 --- /dev/null +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/stories/rectangle-zoom.stories.tsx @@ -0,0 +1,125 @@ +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, + timeScale, + spaceScale, + } = useManchettesWithSpaceTimeChart( + waypoints, + projectPathTrainResult, + manchetteWithSpaceTimeChartRef, + selectedTrain, + DEFAULT_HEIGHT, + spaceTimeChartRef + ); + + return ( +
+
+
+ +
+
+ +
+ + {spaceTimeChartProps.paths.map((path) => ( + + ))} + {spaceTimeChartProps.rect && } + + +
+
+
+
+
time scale: {timeScale.toFixed(0)} ms/px
+
space scale: {spaceScale.toFixed(0)} mm/px
+
+ { + 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..8b96a3b90 --- /dev/null +++ b/ui-charts/src/manchette/useManchetteWithSpaceTimeChart/styles/stories/rectangle-zoom.css @@ -0,0 +1,68 @@ +.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'); + } + } + } + } + + .bottom-controls { + position: absolute; + display: flex; + width: 100%; + bottom: -60px; + .space-time-h-slider-container { + position: absolute; + right: 24px; + + 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'; diff --git a/ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx b/ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx index ec15e45a6..205b0b9f1 100644 --- a/ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx +++ b/ui-charts/src/spaceTimeChart/stories/rectangle-zoom.stories.tsx @@ -8,10 +8,10 @@ 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 { ZoomRect } from '../components/ZoomRect'; import { type Point, type PathData, type OperationalPoint } from '../lib/types'; import { getDiff } from '../utils/vectors'; import { MouseTracker } from './lib/components';