From 9ee9fdc5c1fda2999589ee071a13849699270afb Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 24 Apr 2025 10:29:59 +0200 Subject: [PATCH 01/13] ui-charts: improves splitting manchette and STC Details: - Adds splitting waypoints in the operational points given to the STC from useManchetteWithSpaceTimeChart, and handles not rendering them in the STC code directly - Adds flatSteps in the SpaceTimeChartContext, as a Set - Steps rendering path segments on pauses on flat steps, since we now assume they are rendered directly in the split section layer itself Signed-off-by: Alexis Jacomy --- .../hooks/useManchetteWithSpaceTimeChart.tsx | 47 +++-- .../spaceTimeChart/components/PathLayer.tsx | 179 +++++++++++------- .../components/SpaceGraduations.tsx | 3 + .../components/SpaceTimeChart.tsx | 3 + .../ui-charts/src/spaceTimeChart/lib/types.ts | 1 + .../src/spaceTimeChart/utils/scales.ts | 16 ++ 6 files changed, 155 insertions(+), 94 deletions(-) diff --git a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx index c7b2571b3f1..aef11470007 100644 --- a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx +++ b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx @@ -1,4 +1,4 @@ -import React, { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { Fragment, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { sortBy, clamp } from 'lodash'; @@ -433,15 +433,15 @@ const useManchetteWithSpaceTimeChart = ({ splitPoints, ]); - const waypointsWithoutSplitPoints = useMemo(() => { - const splitPointPositions = new Set(splitPoints?.map((point) => point.position) || []); - const filteredWaypoints = selectWaypointsToDisplay(waypoints, { - height, - isProportional, - yZoom, - }); - return filteredWaypoints.filter((waypoint) => !splitPointPositions.has(waypoint.position)); - }, [splitPoints, waypoints, height, isProportional, yZoom]); + const waypointsToDisplay = useMemo( + () => + selectWaypointsToDisplay(waypoints, { + height, + isProportional, + yZoom, + }), + [waypoints, height, isProportional, yZoom] + ); const { manchetteContents, manchetteHeight } = useMemo(() => { const spaceScaleTree = spaceScalesToBinaryTree(spaceOrigin, spaceScales); @@ -454,21 +454,24 @@ const useManchetteWithSpaceTimeChart = ({ ); } - if (!splitPoints) + if (!splitPoints?.length) return { manchetteHeight: totalManchetteHeight, - manchetteContents: waypointsWithoutSplitPoints, + manchetteContents: waypointsToDisplay, }; // Identify all manchette contents (split sections and waypoints): + const splitPointPositions = new Set(splitPoints.map((point) => point.position) || []); let allContents: ( | { type: 'waypoint'; position: number; waypoint: Waypoint } | { type: 'splitSection'; position: number; split: SplitPoint } - )[] = waypointsWithoutSplitPoints.map((wp) => ({ - type: 'waypoint', - waypoint: wp, - position: wp.position, - })); + )[] = waypointsToDisplay + .filter((wp) => !splitPointPositions.has(wp.position)) + .map((wp) => ({ + type: 'waypoint', + waypoint: wp, + position: wp.position, + })); allContents = allContents.concat( splitPoints.map((sp) => ({ type: 'splitSection', @@ -533,7 +536,7 @@ const useManchetteWithSpaceTimeChart = ({ manchetteHeight: totalManchetteHeight, manchetteContents: finalContents, }; - }, [spaceOrigin, spaceScales, splitPoints, waypointsWithoutSplitPoints, height]); + }, [spaceOrigin, spaceScales, splitPoints, waypointsToDisplay, height]); return useMemo<{ manchetteProps: ManchetteProps; @@ -561,11 +564,13 @@ const useManchetteWithSpaceTimeChart = ({ yOffset, }, spaceTimeChartProps: { - operationalPoints: waypointsWithoutSplitPoints.map((waypoint) => ({ + operationalPoints: waypointsToDisplay.map((waypoint) => ({ ...waypoint, importanceLevel: 1, })), - additionalChildren: splitPoints.map((sp) => sp.spaceTimeChartNode), + additionalChildren: splitPoints.map((sp, i) => ( + {sp.spaceTimeChartNode} + )), timeScale: zoomValueToTimeScale(xZoom), xOffset, yOffset: -yOffset + WAYPOINTS_OFFSET, @@ -674,7 +679,7 @@ const useManchetteWithSpaceTimeChart = ({ yZoom, isProportional, yOffset, - waypointsWithoutSplitPoints, + waypointsToDisplay, xZoom, xOffset, timeOrigin, diff --git a/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx b/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx index a9fd0788e4a..2da7bbaffbf 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx +++ b/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { inRange, last } from 'lodash'; +import { flatten, inRange, last } from 'lodash'; import { useDraw, usePicking } from '../hooks/useCanvas'; import { @@ -106,28 +106,39 @@ export const PathLayer = ({ border, }: PathLayerProps) => { /** - * This function returns the list of points to join to draw the path. It will be both used to - * render the visible path, and the segments on the picking layer. + * This function returns the list of points to join to draw the path. As it can be discontinuous, + * it is returned as a Point[][]. For now, the only case for discontinuous paths is when the path + * stops on a flat step (in which case, we assume the path will be drawn differently in the flat + * step layer). + * + * It will be both used to render the visible path, and the segments on the picking layer. */ - const getPathSegments = useCallback( + const getPathLines = useCallback( ({ getTimePixel, getSpacePixel, spaceScaleTree, + flatSteps, timeAxis, spaceAxis, - }: SpaceTimeChartContextType): Point[] => { - const res: Point[] = []; - path.points.forEach(({ position, time }, i, a) => { + }: SpaceTimeChartContextType): Point[][] => { + const lines: Point[][] = []; + let line: Point[] = []; + const { points } = path; + + for (let i = 0; i < points.length; i++) { + const { position, time } = points[i]; + if (!i) { - res.push({ + line.push({ [timeAxis]: getTimePixel(time), [spaceAxis]: getSpacePixel(position), } as Point); } else { - const { position: prevPosition, time: prevTime } = a[i - 1]; + const { position: prevPosition, time: prevTime } = points[i - 1]; const spaceBreakPoints = getSpaceBreakpoints(prevPosition, position, spaceScaleTree); let previousBreakPosition = -Infinity; + spaceBreakPoints.forEach((breakPosition, index) => { const nextBreakPosition = spaceBreakPoints[index + 1] ?? Infinity; const isBeforeFlatStep = previousBreakPosition === breakPosition; @@ -143,19 +154,28 @@ export const PathLayer = ({ prevTime + ((breakPosition - prevPosition) / (position - prevPosition)) * (time - prevTime); - res.push({ + line.push({ [timeAxis]: getTimePixel(breakTime), [spaceAxis]: getSpacePixel(breakPosition, readSpacePixelFromEnd), } as Point); previousBreakPosition = breakPosition; }); - res.push({ + + const newPoint = { [timeAxis]: getTimePixel(time), [spaceAxis]: getSpacePixel(position), - } as Point); + } as Point; + if (position === prevPosition && flatSteps.has(position)) { + lines.push(line); + line = [newPoint]; + } else { + line.push(newPoint); + } } - }); - return res; + } + + lines.push(line); + return lines; }, [path] ); @@ -194,9 +214,11 @@ export const PathLayer = ({ if (i) { const { position: prevPosition, time: prevTime } = a[i - 1]; if (prevPosition === position && stopPositions.has(position)) { - // Detect flat steps, and draw two graduations if any (one on each side of the step): - getSpacePixels(getSpacePixel, position).forEach((rawPixel) => { - const spacePixel = getCrispLineCoordinate(rawPixel, ctx.lineWidth); + // Only draw the stop when there is no flat step + // (i.e. when there's only one space pixel): + const rawPixels = getSpacePixels(getSpacePixel, position); + if (rawPixels.length === 1) { + const spacePixel = getCrispLineCoordinate(rawPixels[0], ctx.lineWidth); ctx.beginPath(); if (!swapAxis) { ctx.moveTo(getTimePixel(prevTime), spacePixel); @@ -206,7 +228,7 @@ export const PathLayer = ({ ctx.lineTo(spacePixel, getTimePixel(time)); } ctx.stroke(); - }); + } } } }); @@ -336,7 +358,7 @@ export const PathLayer = ({ ); const computePathLength = useCallback( - (operationalPoints: OperationalPoint[], segments: Point[]) => { + (operationalPoints: OperationalPoint[], lines: Point[][]) => { let totalLength = 0; // Compute length of pauses @@ -351,11 +373,13 @@ export const PathLayer = ({ }); // Compute length of pathSegments - segments.forEach(({ x, y }, i, segmentArray) => { - if (i > 0) { - const { x: prevX, y: prevY } = segmentArray[i - 1]; - totalLength += Math.sqrt(Math.pow(prevX - x, 2) + Math.pow(prevY - y, 2)); - } + lines.forEach((points) => { + points.forEach(({ x, y }, i, a) => { + if (i > 0) { + const { x: prevX, y: prevY } = a[i - 1]; + totalLength += Math.sqrt(Math.pow(prevX - x, 2) + Math.pow(prevY - y, 2)); + } + }); }); return totalLength; @@ -370,30 +394,32 @@ export const PathLayer = ({ const mainPathStyle = STYLES[level]; const totalPathWidth = border.offset * 2 + mainPathStyle.width; const backgroundColor = border.backgroundColor || '#fff'; - const segments = getPathSegments(stcContext); + const lines = getPathLines(stcContext); ctx.save(); - ctx.beginPath(); - const drawSegments = (lineWidth: number, borderColor = border.color) => { + const drawLines = (lineWidth: number, borderColor = border.color) => { ctx.strokeStyle = borderColor; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; - segments.forEach(({ x, y }, i) => { - if (x === segments[i - 1]?.x && y === segments[i - 1]?.y) return; - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } + lines.forEach((segments) => { + ctx.beginPath(); + segments.forEach(({ x, y }, i) => { + if (x === segments[i - 1]?.x && y === segments[i - 1]?.y) return; + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.stroke(); }); - ctx.stroke(); }; - drawSegments(totalPathWidth + borderWidth * 2); - drawSegments(totalPathWidth, backgroundColor); + drawLines(totalPathWidth + borderWidth * 2); + drawLines(totalPathWidth, backgroundColor); ctx.restore(); }, - [border, getPathSegments, level] + [border, getPathLines, level] ); const drawAll = useCallback( @@ -415,16 +441,18 @@ export const PathLayer = ({ ctx.setLineDash(style.dashArray || []); ctx.globalAlpha = style.opacity || 1; ctx.lineCap = style.lineCap || 'square'; - ctx.beginPath(); - const segments = getPathSegments(stcContext); - segments.forEach(({ x, y }, i) => { - if (!i) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } + const lines = getPathLines(stcContext); + lines.forEach((points) => { + ctx.beginPath(); + points.forEach(({ x, y }, i) => { + if (!i) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.stroke(); }); - ctx.stroke(); // Draw extremities: ctx.setLineDash([]); @@ -433,15 +461,18 @@ export const PathLayer = ({ // Draw label: if (!stcContext.hidePathsLabels) { - const pathLength = computePathLength(stcContext.operationalPoints, segments); - drawLabel(ctx, stcContext, path.label, color, segments, pathLength); + const pathLength = computePathLength(stcContext.operationalPoints, lines); + // TODO: + // We should improve how the labels are drawn, and handle discontinuous lines (instead of + // flattening the points) + drawLabel(ctx, stcContext, path.label, color, flatten(lines), pathLength); } }, [ color, drawPauses, level, - getPathSegments, + getPathLines, drawExtremities, computePathLength, drawLabel, @@ -456,28 +487,30 @@ export const PathLayer = ({ const { registerPickingElement } = stcContext; // Draw segments: - getPathSegments(stcContext).forEach((point, i, a) => { - if (i) { - const previousPoint = a[i - 1]; - const pickingElement: SegmentPickingElement = { - type: 'segment', - pathId: path.id, - from: previousPoint, - to: point, - }; - const index = registerPickingElement(pickingElement); - const lineColor = hexToRgb(indexToColor(index)); - drawAliasedLine( - imageData, - previousPoint, - point, - lineColor, - STYLES[level].width + pickingTolerance, - true, - scalingRatio - ); - } - }); + getPathLines(stcContext).forEach((line) => + line.forEach((point, i, a) => { + if (i) { + const previousPoint = a[i - 1]; + const pickingElement: SegmentPickingElement = { + type: 'segment', + pathId: path.id, + from: previousPoint, + to: point, + }; + const index = registerPickingElement(pickingElement); + const lineColor = hexToRgb(indexToColor(index)); + drawAliasedLine( + imageData, + previousPoint, + point, + lineColor, + STYLES[level].width + pickingTolerance, + true, + scalingRatio + ); + } + }) + ); // Draw snap points: getSnapPoints(stcContext).forEach((point) => { @@ -498,7 +531,7 @@ export const PathLayer = ({ ); }); }, - [getPathSegments, getSnapPoints, level, path.id, pickingTolerance] + [getPathLines, getSnapPoints, level, path.id, pickingTolerance] ); usePicking('paths', drawPicking); diff --git a/front/ui/ui-charts/src/spaceTimeChart/components/SpaceGraduations.tsx b/front/ui/ui-charts/src/spaceTimeChart/components/SpaceGraduations.tsx index 691af4f1bb9..9b3fca245ac 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/components/SpaceGraduations.tsx +++ b/front/ui/ui-charts/src/spaceTimeChart/components/SpaceGraduations.tsx @@ -13,6 +13,7 @@ const SpaceGraduations = () => { timePixelOffset, getSpacePixel, operationalPoints, + flatSteps, swapAxis, width, height, @@ -23,6 +24,8 @@ const SpaceGraduations = () => { // Draw operational point lines: operationalPoints.forEach((point) => { + if (flatSteps.has(point.position)) return; + const styles = spaceGraduationsStyles[point.importanceLevel || 0]; if (!styles) return; diff --git a/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx b/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx index d92c78f6d98..98f144a24da 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx +++ b/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx @@ -20,6 +20,7 @@ import { } from '../lib/types'; import { getDataToPoint, + getFlatSteps, getPixelToSpace, getPixelToTime, getPointToData, @@ -100,6 +101,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { const contextState: SpaceTimeChartContextType = useMemo(() => { const spaceScaleTree = spaceScalesToBinaryTree(spaceOrigin, spaceScales); + const flatSteps = getFlatSteps(spaceScales); const timeAxis = !swapAxis ? 'x' : 'y'; const spaceAxis = !swapAxis ? 'y' : 'x'; @@ -147,6 +149,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { operationalPoints, spaceOrigin, spaceScaleTree, + flatSteps, timeOrigin, timeScale, timePixelOffset, diff --git a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts index 3c9f1a5bc86..19240cab7df 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts @@ -270,6 +270,7 @@ export type SpaceTimeChartContextType = { timeScale: number; spaceOrigin: number; spaceScaleTree: NormalizedScaleTree; + flatSteps: Set; // Translation helpers: getTimePixel: TimeToPixel; diff --git a/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts b/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts index 4d91f8eed4d..ecaa3dfc290 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts @@ -101,6 +101,22 @@ export function spaceScalesToBinaryTree( return buildTree(normalizedScales); } +/** + * This function takes a sequence of SpaceScales, identifies the flat steps (i.e. the scales that do + * not increase the position), and returns them in a set. + */ +export function getFlatSteps(spaceScales: SpaceScale[]): Set { + const flatSteps: number[] = []; + + for (let i = 1; i < spaceScales.length; i++) { + const { to: previous } = spaceScales[i - 1]; + const { to: current } = spaceScales[i]; + if (previous === current) flatSteps.push(current); + } + + return new Set(flatSteps); +} + /** * This function takes a NormalizedScaleTree and a position, and returns the leaf node from the * tree that contains that position. From 9b98f31bd20b1537366a33c33bdd56cc38205615 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 25 Apr 2025 14:35:06 +0200 Subject: [PATCH 02/13] ui-charts: makes space-time chart more resilient This commit allows the SpaceTimeChart to work with space scales that just contain one single flat step. The idea is to allow using the SpaceTimeChart, to only render a TrackOccupancyDiagram, while still having the other interesting interactions (zooming and panning, basically). Details: - Adds new hideTimeCaptions param - Improves utils to allow the SpaceTimeChart to work with only one waypoint, and a flat step on it Signed-off-by: Alexis Jacomy --- .../spaceTimeChart/__tests__/scales.spec.ts | 28 +++++++++++++++++++ .../components/SpaceTimeChart.tsx | 4 +++ .../components/TimeCaptions.tsx | 3 ++ .../src/spaceTimeChart/hooks/useCanvas.ts | 2 +- .../ui-charts/src/spaceTimeChart/lib/types.ts | 2 ++ .../src/spaceTimeChart/utils/scales.ts | 7 ++++- 6 files changed, 44 insertions(+), 2 deletions(-) diff --git a/front/ui/ui-charts/src/spaceTimeChart/__tests__/scales.spec.ts b/front/ui/ui-charts/src/spaceTimeChart/__tests__/scales.spec.ts index d746eb3d3ec..03e97752923 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/__tests__/scales.spec.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/__tests__/scales.spec.ts @@ -103,6 +103,16 @@ describe('spaceScalesToBinaryTree', () => { pixelTo: 250, }); }); + + it('should work properly with weird flat-step-only scales', () => { + expect(spaceScalesToBinaryTree(0, [{ to: 0, size: 50 }])).toEqual({ + coefficient: 0 / 50, + from: 0, + to: 0, + pixelFrom: 0, + pixelTo: 50, + }); + }); }); describe('getSpaceBreakpoints', () => { @@ -185,6 +195,24 @@ describe('getNormalizedScaleAtPosition', () => { }); }); + it('should work with ONLY a flat section', () => { + const tree = spaceScalesToBinaryTree(ORIGIN, [{ to: 0, size: 50 }]); + + expect( + pick(getNormalizedScaleAtPosition(0, tree) as NormalizedScale, 'pixelFrom', 'pixelTo') + ).toEqual({ + pixelFrom: 0, + pixelTo: 50, + }); + + expect( + pick(getNormalizedScaleAtPosition(0, tree, true) as NormalizedScale, 'pixelFrom', 'pixelTo') + ).toEqual({ + pixelFrom: 0, + pixelTo: 50, + }); + }); + it('should return extremities when out of scope', () => { expect(pick(getNormalizedScaleAtPosition(-1, TREE) as NormalizedScale, 'from', 'to')).toEqual({ from: 0, diff --git a/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx b/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx index 98f144a24da..ac370687ef0 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx +++ b/front/ui/ui-charts/src/spaceTimeChart/components/SpaceTimeChart.tsx @@ -44,6 +44,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { children, additionalChildren, enableSnapping, + hideTimeCaptions, hideGrid, hidePathsLabels, showTicks, @@ -76,6 +77,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { xOffset, yOffset, swapAxis, + hideTimeCaptions, hideGrid, hidePathsLabels, showTicks, @@ -92,6 +94,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { xOffset, yOffset, swapAxis, + hideTimeCaptions, hideGrid, hidePathsLabels, showTicks, @@ -158,6 +161,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { spaceAxis, swapAxis: !!swapAxis, enableSnapping: !!enableSnapping, + hideTimeCaptions: !!hideTimeCaptions, hideGrid: !!hideGrid, hidePathsLabels: !!hidePathsLabels, showTicks: !!showTicks, diff --git a/front/ui/ui-charts/src/spaceTimeChart/components/TimeCaptions.tsx b/front/ui/ui-charts/src/spaceTimeChart/components/TimeCaptions.tsx index 15ea39dedc7..42a1b1fa04f 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/components/TimeCaptions.tsx +++ b/front/ui/ui-charts/src/spaceTimeChart/components/TimeCaptions.tsx @@ -62,10 +62,13 @@ export const TimeCaptions = () => { dateCaptionsStyle, }, captionSize, + hideTimeCaptions, hideDates, showTicks, } ) => { + if (hideTimeCaptions) return; + const timeAxisSize = !swapAxis ? width : height; const spaceAxisSize = (!swapAxis ? height : width) - captionSize; diff --git a/front/ui/ui-charts/src/spaceTimeChart/hooks/useCanvas.ts b/front/ui/ui-charts/src/spaceTimeChart/hooks/useCanvas.ts index 76188768b36..8569534e9b5 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/hooks/useCanvas.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/hooks/useCanvas.ts @@ -74,7 +74,7 @@ export function useCanvas( const ctx = contextsRef.current[`${PICKING}-${layer}`]; const set = pickingFunctions.current[layer]; - if (ctx) { + if (ctx && ctx.canvas.width && ctx.canvas.height) { const { width, height } = sizeRef.current; ctx.clearRect(0, 0, width, height); diff --git a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts index 19240cab7df..11ecacdd356 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts @@ -204,6 +204,7 @@ export type SpaceTimeChartProps = { enableSnapping?: boolean; // Additional options to show/hide context information: + hideTimeCaptions?: boolean; hideGrid?: boolean; hidePathsLabels?: boolean; hideDates?: boolean; @@ -291,6 +292,7 @@ export type SpaceTimeChartContextType = { // Other options: enableSnapping: boolean; + hideTimeCaptions: boolean; hideGrid: boolean; hidePathsLabels: boolean; hideDates: boolean; diff --git a/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts b/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts index ecaa3dfc290..c817afa1216 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts @@ -188,11 +188,16 @@ export function getSpaceToPixel( binaryTree: NormalizedScaleTree ): SpaceToPixel { return (position: number, fromEnd?: boolean) => { - const { from, pixelFrom, coefficient } = getNormalizedScaleAtPosition( + const { from, pixelFrom, pixelTo, coefficient } = getNormalizedScaleAtPosition( position, binaryTree, fromEnd ); + // Rare case where coefficient is 0: + // (occurs when there is just a flat step, for instance) + if (!coefficient) return pixelOffset + (fromEnd ? pixelTo : pixelFrom); + + // Normal case: We simply interpolate return pixelOffset + pixelFrom + (position - from) / coefficient; }; } From 5fd4fee017a9736321d8b25bd21813eb2ed86451 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 25 Apr 2025 14:40:02 +0200 Subject: [PATCH 03/13] ui-charts: makes manchette more resilient This commit aims at making Manchette and useManchetteWithSpaceTimeChart work with a single flat step, on a single waypoint. This will allow them to be used to render a TrackOccupancyDiagram, with all the interesting interactions (zooming and panning). Details: - Adds various options to useManchetteWithSpaceTimeChart, to allow disabling zooming, panning or time captions - Allows overriding vertical padding in useManchetteWithSpaceTimeChart, to exactly fit one single flat step, to the pixel - Improves helpers (and their tests) to work in the edge case where there is just one waypoint, and a flat step on it Signed-off-by: Alexis Jacomy --- .../hooks/useManchetteWithSpaceTimeChart.tsx | 134 +++++++++++------- .../src/manchette/styles/manchette.css | 5 +- .../manchette/utils/__tests__/helpers.spec.ts | 43 ++++-- .../ui-charts/src/manchette/utils/helpers.ts | 15 +- 4 files changed, 132 insertions(+), 65 deletions(-) diff --git a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx index aef11470007..d45489bb588 100644 --- a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx +++ b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx @@ -33,8 +33,6 @@ import { zoomX, } from '../utils/helpers'; -const WAYPOINTS_OFFSET = 16; - type State = { xZoom: number; yZoom: number; @@ -78,6 +76,20 @@ export type SplitPoint = { manchetteNode?: ReactNode; }; +export type ManchetteWithSpaceTimeChartOptions = { + displayTimeCaptions: boolean; + enableTimePan: boolean; + enableSpacePan: boolean; + enableTimeZoom: boolean; +}; + +export const DEFAULT_MANCHETTE_WITH_SPACE_TIME_CHART_OPTIONS: ManchetteWithSpaceTimeChartOptions = { + displayTimeCaptions: true, + enableTimePan: true, + enableSpacePan: true, + enableTimeZoom: true, +}; + const useManchetteWithSpaceTimeChart = ({ waypoints, manchetteWithSpaceTimeChartRef, @@ -85,7 +97,10 @@ const useManchetteWithSpaceTimeChart = ({ spaceTimeChartRef, defaultTimeOrigin = 0, defaultSpaceOrigin = 0, + defaultXOffset = 0, + verticalPadding = BASE_WAYPOINT_HEIGHT / 2, splitPoints = [], + options = {}, }: { waypoints: Waypoint[]; manchetteWithSpaceTimeChartRef: React.RefObject; @@ -93,15 +108,25 @@ const useManchetteWithSpaceTimeChart = ({ spaceTimeChartRef?: React.RefObject; defaultTimeOrigin?: number; defaultSpaceOrigin?: number; + defaultXOffset?: number; + verticalPadding?: number; splitPoints?: SplitPoint[]; + options?: Partial; }) => { + const { displayTimeCaptions, enableTimePan, enableSpacePan, enableTimeZoom } = useMemo( + () => ({ + ...DEFAULT_MANCHETTE_WITH_SPACE_TIME_CHART_OPTIONS, + ...options, + }), + [options] + ); const [isShiftPressed, setIsShiftPressed] = useState(false); const [state, setState] = useState({ xZoom: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX), yZoom: 1, timeOrigin: defaultTimeOrigin, spaceOrigin: defaultSpaceOrigin, - xOffset: 0, + xOffset: defaultXOffset, yOffset: 0, scrollTo: null, panning: null, @@ -133,7 +158,7 @@ const useManchetteWithSpaceTimeChart = ({ setState((prev) => ({ ...prev, timeOrigin: newTimeOrigin })); }, []); - const canvasDrawingHeight = height - FOOTER_HEIGHT; // 521 + const canvasDrawingHeight = Math.max(1 + BASE_WAYPOINT_HEIGHT, height - FOOTER_HEIGHT); // 521 const drawingHeightWithoutTopPadding = canvasDrawingHeight - BASE_WAYPOINT_HEIGHT / 2; // 505 const drawingHeightWithoutBothPadding = canvasDrawingHeight - BASE_WAYPOINT_HEIGHT; // 489 const totalDistance = calcTotalDistance(waypoints); @@ -344,12 +369,13 @@ const useManchetteWithSpaceTimeChart = ({ const handleXZoom = useCallback( (newXZoom: number, xPosition = (spaceTimeChartRef?.current?.offsetWidth || 0) / 2) => { - setState((prev) => ({ - ...prev, - ...zoomX(prev.xZoom, prev.xOffset, newXZoom, xPosition), - })); + if (enableTimeZoom) + setState((prev) => ({ + ...prev, + ...zoomX(prev.xZoom, prev.xOffset, newXZoom, xPosition), + })); }, - [spaceTimeChartRef] + [enableTimeZoom, spaceTimeChartRef] ); const spaceScales = useMemo(() => { @@ -373,20 +399,31 @@ const useManchetteWithSpaceTimeChart = ({ : (baseScale.to - baseScale.from) / baseScale.size; return splitPoints - .flatMap(({ position, size: splitPointHeight }) => [ - { - to: position, - coefficient, - }, - { - to: position, - size: splitPointHeight, - }, - ]) - .concat({ - to: baseScales.at(-1)!.to, - coefficient, - }); + .flatMap(({ position, size: splitPointHeight }) => + coefficient > 0 + ? [ + { + to: position, + coefficient, + }, + { + to: position, + size: splitPointHeight, + }, + ] + : { + to: position, + size: splitPointHeight, + } + ) + .concat( + coefficient + ? { + to: baseScales.at(-1)!.to, + coefficient, + } + : [] + ); } // Varying scales: @@ -450,16 +487,10 @@ const useManchetteWithSpaceTimeChart = ({ if (spaceScales.length > 0) { totalManchetteHeight = Math.max( totalManchetteHeight, - getSpacePixel(spaceScales.at(-1)!.to, true) + BASE_WAYPOINT_HEIGHT + getSpacePixel(spaceScales.at(-1)!.to, true) + verticalPadding * 2 ); } - if (!splitPoints?.length) - return { - manchetteHeight: totalManchetteHeight, - manchetteContents: waypointsToDisplay, - }; - // Identify all manchette contents (split sections and waypoints): const splitPointPositions = new Set(splitPoints.map((point) => point.position) || []); let allContents: ( @@ -513,12 +544,12 @@ const useManchetteWithSpaceTimeChart = ({ finalContents.push(
( {sp.spaceTimeChartNode} )), + hideTimeCaptions: !displayTimeCaptions, timeScale: zoomValueToTimeScale(xZoom), xOffset, - yOffset: -yOffset + WAYPOINTS_OFFSET, + yOffset: -yOffset + verticalPadding, timeOrigin, spaceOrigin, spaceScales, @@ -643,18 +675,20 @@ const useManchetteWithSpaceTimeChart = ({ const newState = { ...prev }; const { initialOffset } = panning; - newState.xOffset = initialOffset.x + diff.x; - - const newYPos = initialOffset.y - diff.y; - if ( - manchetteWithSpaceTimeChartRef.current && - newYPos >= 0 && - newYPos + manchetteWithSpaceTimeChartRef.current.offsetHeight < - manchetteWithSpaceTimeChartRef.current.scrollHeight - ) { - newState.yOffset = newYPos; - manchetteWithSpaceTimeChartRef.current.scrollTop = newYPos; + const manchette = manchetteWithSpaceTimeChartRef.current; + + if (enableTimePan) { + newState.xOffset = initialOffset.x + diff.x; } + if (enableSpacePan) { + let newYOffset = initialOffset.y - diff.y; + newYOffset = Math.max(newYOffset, 0); + if (manchette) + newYOffset = Math.min(newYOffset, manchette.scrollHeight - manchette.offsetHeight); + newState.yOffset = newYOffset; + if (manchette) manchette.scrollTop = newYOffset; + } + return newState; }); }, @@ -680,12 +714,13 @@ const useManchetteWithSpaceTimeChart = ({ isProportional, yOffset, waypointsToDisplay, + splitPoints, xZoom, xOffset, + verticalPadding, timeOrigin, spaceOrigin, spaceScales, - splitPoints, handleScroll, handleXZoom, toggleZoomMode, @@ -694,10 +729,13 @@ const useManchetteWithSpaceTimeChart = ({ minZoomMillimeterPerPx, maxZoomMillimeterPerPx, setTimeOrigin, + displayTimeCaptions, isShiftPressed, panning, - manchetteWithSpaceTimeChartRef, + enableTimePan, + enableSpacePan, canvasDrawingHeight, + manchetteWithSpaceTimeChartRef, ] ); }; diff --git a/front/ui/ui-charts/src/manchette/styles/manchette.css b/front/ui/ui-charts/src/manchette/styles/manchette.css index f682302d166..a1307e9888d 100644 --- a/front/ui/ui-charts/src/manchette/styles/manchette.css +++ b/front/ui/ui-charts/src/manchette/styles/manchette.css @@ -59,10 +59,13 @@ .waypoints-list { width: 349px; border-bottom-left-radius: 0.25rem; - padding-inline: 0.5rem 1.625rem; @apply bg-white-100; + .waypoint-wrapper { + margin-inline: 0.5rem 1.625rem; + } + .waypoint-wrapper:last-child { .waypoint { &::after { diff --git a/front/ui/ui-charts/src/manchette/utils/__tests__/helpers.spec.ts b/front/ui/ui-charts/src/manchette/utils/__tests__/helpers.spec.ts index db25be2457f..64282135693 100644 --- a/front/ui/ui-charts/src/manchette/utils/__tests__/helpers.spec.ts +++ b/front/ui/ui-charts/src/manchette/utils/__tests__/helpers.spec.ts @@ -19,7 +19,7 @@ const mockedWaypoints = [ ]; describe('selectWaypointsToDisplay', () => { - it('should ensure that a empty array is returned when there is only 1 waypoint', () => { + it('should ensure that an empty array is returned when there is only 1 waypoint', () => { const result = selectWaypointsToDisplay([mockedWaypoints[0]], { height: 500, isProportional: true, @@ -84,19 +84,34 @@ describe('getScales', () => { importanceLevel: 1, })); - 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, - }, - minZoomMillimeterPerPx, - maxZoomMillimeterPerPx - ); - expect(result).toHaveLength(0); + it('should ensure that an empty array is return when there is no waypoint', () => { + expect( + getScales( + [], + { + height: 500, + isProportional: true, + yZoom: 1, + }, + minZoomMillimeterPerPx, + maxZoomMillimeterPerPx + ) + ).toHaveLength(0); + }); + + it('should return correct one single scale when there is just one waypoint', () => { + expect( + getScales( + [mockOpsWithPosition[0]], + { + height: 500, + isProportional: true, + yZoom: 1, + }, + minZoomMillimeterPerPx, + maxZoomMillimeterPerPx + ) + ).toEqual([{ from: 0, to: 0, size: 500 }]); }); it('should return correct scale coefficients for proportional display', () => { diff --git a/front/ui/ui-charts/src/manchette/utils/helpers.ts b/front/ui/ui-charts/src/manchette/utils/helpers.ts index 40a99f4b42a..fde09eaaca8 100644 --- a/front/ui/ui-charts/src/manchette/utils/helpers.ts +++ b/front/ui/ui-charts/src/manchette/utils/helpers.ts @@ -132,11 +132,22 @@ export const selectWaypointsToDisplay = ( */ export const getScales = ( waypoints: Waypoint[], - { isProportional, yZoom }: WaypointsOptions, + { isProportional, yZoom, height }: WaypointsOptions, minZoomMillimeterPerPx: number, maxZoomMillimeterPerPx: number ) => { - if (waypoints.length < 2) return []; + if (!waypoints.length) return []; + + if (waypoints.length === 1) { + const waypoint = waypoints[0]; + return [ + { + from: waypoint.position, + to: waypoint.position, + size: height || 1, + }, + ]; + } if (!isProportional) { return waypoints.slice(0, -1).map((from, index) => { From 1cc625c0d783de44a5968eb0207b7bda9f4d027e Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 25 Apr 2025 15:05:15 +0200 Subject: [PATCH 04/13] ui-charts: adds new TrackOccupancyStandalone This commit basically replaces the previous trackOccupancyDiagram/rendering story, with a new component, TrackOccupancyStandalone, that actually calls useManchetteWithSpaceTimeChart instead of mimicking it. The main benefit is to get proper interactions, such as panning on the time axis. Also, this commit is very impactful, because many things were done quite customly for track occupancy drawing, when the SpaceTimeChart APIs provide many tools to help rendering additional layers on top of them. One final note, before getting into the detail: This commit breaks selecting a train with the mouse on the TrackOccupancyDiagram, because this was done in the rendering loop, rather than using picking (as it should). Details: - Replaces all time and space to pixels conversions from track occupancy layers, with code that uses proper tools from SpaceTimeChartContext - Removes mouse collision detection (it was done in the rendering process, which meant that rendering was depending on the mousePositions, causing many excess renderings) - Updates OccupancyZone: id becomes trainId, and arrivalTime and departureTime become numbers instead of dates - Adds new TrackOccupancyStandalone component - Updates rendering story to use new component Signed-off-by: Alexis Jacomy --- front/ui/storybook/.storybook/preview.ts | 2 - .../ui-charts/spaceTimeChart/helpers/paths.ts | 5 +- .../assets/occupancyZones.ts | 294 +++++++++--------- .../rendering.stories.tsx | 293 ++--------------- .../styles/track-occupancy.css | 3 + front/ui/storybook/styles/global.css | 1 - .../trackOccupancyDiagram/baseStory.css | 39 --- .../ui-charts/src/spaceTimeChart/lib/types.ts | 6 - .../components/TrackOccupancyCanvas.tsx | 33 +- .../components/TrackOccupancyManchette.tsx | 6 +- .../components/TrackOccupancyStandalone.tsx | 105 +++++++ .../components/consts.ts | 4 +- .../drawElements/drawOccupancyZones.ts | 274 +++++++--------- .../drawElements/drawOccupancyZonesTexts.ts | 14 +- .../helpers/drawElements/drawTrack.ts | 7 +- .../helpers/drawElements/drawTracks.ts | 114 +++---- .../components/layers/OccupancyZonesLayer.tsx | 41 ++- .../components/layers/TracksLayer.tsx | 37 +-- .../trackOccupancyDiagram/components/types.ts | 22 +- .../src/trackOccupancyDiagram/index.ts | 3 + .../src/trackOccupancyDiagram/styles/main.css | 47 ++- 21 files changed, 541 insertions(+), 809 deletions(-) create mode 100644 front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/styles/track-occupancy.css delete mode 100644 front/ui/storybook/styles/global.css delete mode 100644 front/ui/storybook/styles/trackOccupancyDiagram/baseStory.css create mode 100644 front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx diff --git a/front/ui/storybook/.storybook/preview.ts b/front/ui/storybook/.storybook/preview.ts index 6c33a37aa95..817ac3ce6b9 100644 --- a/front/ui/storybook/.storybook/preview.ts +++ b/front/ui/storybook/.storybook/preview.ts @@ -1,7 +1,5 @@ import type { Preview } from '@storybook/react'; -import '../styles/global.css'; - const preview: Preview = { parameters: { actions: { argTypesRegex: '^on[A-Z].*' }, diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts index 2ba20676416..42a62a84e4b 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts @@ -122,7 +122,7 @@ export const START_DATE = new Date('2024/04/02'); // TODO: // Store and share the hardcoded colors with other stories that use the GET as well -export const PATHS: (PathData & { +export type PathDisplay = PathData & { color: string; border?: { offset: number; @@ -131,7 +131,8 @@ export const PATHS: (PathData & { backgroundColor?: string; }; level?: PathLevel; -})[] = [ +}; +export const PATHS: PathDisplay[] = [ // Paced Train ...getPaths( 'Paced', diff --git a/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/assets/occupancyZones.ts b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/assets/occupancyZones.ts index f1a83b730c8..f2d683b5c17 100755 --- a/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/assets/occupancyZones.ts +++ b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/assets/occupancyZones.ts @@ -1,532 +1,534 @@ -const OccupancyZones = [ +import { OccupancyZone } from '@osrd-project/ui-charts'; + +const OCCUPANCY_ZONES: OccupancyZone[] = [ { - id: '1', + trainId: '1', trackId: '1', arrivalTrainName: '241536', departureTrainName: '241537', color: 'rgb(121, 118, 113)', originStation: 'FOO', destinationStation: 'BAR', - arrivalTime: new Date('2024/04/02 01:30'), - departureTime: new Date('2024/04/02 01:30'), + arrivalTime: new Date('2024/04/02 01:30').getTime(), + departureTime: new Date('2024/04/02 01:30').getTime(), }, { - id: '2', + trainId: '2', trackId: '2', arrivalTrainName: '524136', departureTrainName: '524137', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:20'), + arrivalTime: new Date('2024/04/02 00:20').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 00:23'), + departureTime: new Date('2024/04/02 00:23').getTime(), }, { - id: '3', + trainId: '3', trackId: '3', arrivalTrainName: '356124', departureTrainName: '356125', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:43'), + arrivalTime: new Date('2024/04/02 00:43').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 00:53'), + departureTime: new Date('2024/04/02 00:53').getTime(), }, { - id: '4', + trainId: '4', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:24'), + arrivalTime: new Date('2024/04/02 00:24').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:50'), + departureTime: new Date('2024/04/02 00:50').getTime(), }, { - id: '5', + trainId: '5', trackId: '5', arrivalTrainName: '356421', departureTrainName: '356422', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:22'), + arrivalTime: new Date('2024/04/02 00:22').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:32'), + departureTime: new Date('2024/04/02 00:32').getTime(), }, { - id: '6', + trainId: '6', trackId: '6', arrivalTrainName: '634215', departureTrainName: '634216', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:12'), + arrivalTime: new Date('2024/04/02 00:12').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:15'), + departureTime: new Date('2024/04/02 00:15').getTime(), }, { - id: '7', + trainId: '7', trackId: '4', arrivalTrainName: '316452', departureTrainName: '316453', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:39'), + arrivalTime: new Date('2024/04/02 01:39').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:44'), + departureTime: new Date('2024/04/02 02:44').getTime(), }, { - id: '8', + trainId: '8', trackId: '3', arrivalTrainName: '165234', departureTrainName: '165235', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:43'), + arrivalTime: new Date('2024/04/02 01:43').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 01:53'), + departureTime: new Date('2024/04/02 01:53').getTime(), }, { - id: '9', + trainId: '9', trackId: '1', arrivalTrainName: '463512', departureTrainName: '463513', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:40'), + arrivalTime: new Date('2024/04/02 00:40').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 00:40'), + departureTime: new Date('2024/04/02 00:40').getTime(), }, { - id: '10', + trainId: '10', trackId: '6', arrivalTrainName: '215643', departureTrainName: '215644', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 03:12'), + arrivalTime: new Date('2024/04/02 03:12').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 03:15'), + departureTime: new Date('2024/04/02 03:15').getTime(), }, { - id: '11', + trainId: '11', trackId: '2', arrivalTrainName: '645312', departureTrainName: '645313', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:20'), + arrivalTime: new Date('2024/04/02 01:20').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 01:23'), + departureTime: new Date('2024/04/02 01:23').getTime(), }, { - id: '12', + trainId: '12', trackId: '2', arrivalTrainName: '635214', departureTrainName: '635215', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:20'), + arrivalTime: new Date('2024/04/02 02:20').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 02:23'), + departureTime: new Date('2024/04/02 02:23').getTime(), }, { - id: '13', + trainId: '13', trackId: '2', arrivalTrainName: '352614', departureTrainName: '352615', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 03:20'), + arrivalTime: new Date('2024/04/02 03:20').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 03:23'), + departureTime: new Date('2024/04/02 03:23').getTime(), }, { - id: '14', + trainId: '14', trackId: '6', arrivalTrainName: '342615', departureTrainName: '342616', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:12'), + arrivalTime: new Date('2024/04/02 01:12').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 01:15'), + departureTime: new Date('2024/04/02 01:15').getTime(), }, { - id: '15', + trainId: '15', trackId: '6', arrivalTrainName: '132645', departureTrainName: '132646', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:12'), + arrivalTime: new Date('2024/04/02 02:12').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:15'), + departureTime: new Date('2024/04/02 02:15').getTime(), }, { - id: '16', + trainId: '16', trackId: '5', arrivalTrainName: '642531', departureTrainName: '642532', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:22'), + arrivalTime: new Date('2024/04/02 01:22').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 01:32'), + departureTime: new Date('2024/04/02 01:32').getTime(), }, { - id: '17', + trainId: '17', trackId: '5', arrivalTrainName: '652341', departureTrainName: '652342', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:22'), + arrivalTime: new Date('2024/04/02 02:22').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:32'), + departureTime: new Date('2024/04/02 02:32').getTime(), }, { - id: '18', + trainId: '18', trackId: '5', arrivalTrainName: '423561', departureTrainName: '423562', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 03:22'), + arrivalTime: new Date('2024/04/02 03:22').getTime(), originStation: 'BAR', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 03:32'), + departureTime: new Date('2024/04/02 03:32').getTime(), }, { - id: '19', + trainId: '19', trackId: '3', arrivalTrainName: '123456', departureTrainName: '123457', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:43'), + arrivalTime: new Date('2024/04/02 02:43').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 02:53'), + departureTime: new Date('2024/04/02 02:53').getTime(), }, { - id: '20', + trainId: '20', trackId: '3', arrivalTrainName: '153264', departureTrainName: '153265', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 03:43'), + arrivalTime: new Date('2024/04/02 03:43').getTime(), originStation: 'FOO', destinationStation: 'BAR', - departureTime: new Date('2024/04/02 03:53'), + departureTime: new Date('2024/04/02 03:53').getTime(), }, { - id: '21', + trainId: '21', trackId: '1', arrivalTrainName: '465321', departureTrainName: '465322', color: 'rgb(121, 118, 113)', originStation: 'FOO', destinationStation: 'BAR', - arrivalTime: new Date('2024/04/02 02:59'), - departureTime: new Date('2024/04/02 02:59'), + arrivalTime: new Date('2024/04/02 02:59').getTime(), + departureTime: new Date('2024/04/02 02:59').getTime(), }, { - id: '23', + trainId: '23', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:34'), + arrivalTime: new Date('2024/04/02 00:34').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 01:05'), + departureTime: new Date('2024/04/02 01:05').getTime(), }, { - id: '24', + trainId: '24', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:57'), + arrivalTime: new Date('2024/04/02 00:57').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 01:02'), + departureTime: new Date('2024/04/02 01:02').getTime(), }, { - id: '25', + trainId: '25', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:46'), + arrivalTime: new Date('2024/04/02 00:46').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 01:11'), + departureTime: new Date('2024/04/02 01:11').getTime(), }, { - id: '26', + trainId: '26', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:49'), + arrivalTime: new Date('2024/04/02 01:49').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:14'), + departureTime: new Date('2024/04/02 02:14').getTime(), }, { - id: '27', + trainId: '27', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:54'), + arrivalTime: new Date('2024/04/02 01:54').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:07'), + departureTime: new Date('2024/04/02 02:07').getTime(), }, { - id: '28', + trainId: '28', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:00'), + arrivalTime: new Date('2024/04/02 02:00').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:11'), + departureTime: new Date('2024/04/02 02:11').getTime(), }, { - id: '29', + trainId: '29', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:05'), + arrivalTime: new Date('2024/04/02 02:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:09'), + departureTime: new Date('2024/04/02 02:09').getTime(), }, { - id: '30', + trainId: '30', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:10'), + arrivalTime: new Date('2024/04/02 02:10').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:17'), + departureTime: new Date('2024/04/02 02:17').getTime(), }, { - id: '31', + trainId: '31', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:19'), + arrivalTime: new Date('2024/04/02 02:19').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:29'), + departureTime: new Date('2024/04/02 02:29').getTime(), }, { - id: '32', + trainId: '32', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:24'), + arrivalTime: new Date('2024/04/02 02:24').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:26'), + departureTime: new Date('2024/04/02 02:26').getTime(), }, { - id: '33', + trainId: '33', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:29'), + arrivalTime: new Date('2024/04/02 02:29').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:57'), + departureTime: new Date('2024/04/02 02:57').getTime(), }, { - id: '34', + trainId: '34', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:34'), + arrivalTime: new Date('2024/04/02 02:34').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:53'), + departureTime: new Date('2024/04/02 02:53').getTime(), }, { - id: '33', + trainId: '33', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:09'), + arrivalTime: new Date('2024/04/02 02:09').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 03:07'), + departureTime: new Date('2024/04/02 03:07').getTime(), }, { - id: '34', + trainId: '34', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 02:12'), + arrivalTime: new Date('2024/04/02 02:12').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 02:59'), + departureTime: new Date('2024/04/02 02:59').getTime(), }, { - id: '35', + trainId: '35', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 03:12'), + arrivalTime: new Date('2024/04/02 03:12').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 03:20'), + departureTime: new Date('2024/04/02 03:20').getTime(), }, { - id: '36', + trainId: '36', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '37', + trainId: '37', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 01:20'), + arrivalTime: new Date('2024/04/02 01:20').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 01:25'), + departureTime: new Date('2024/04/02 01:25').getTime(), }, { - id: '38', + trainId: '38', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '39', + trainId: '39', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '40', + trainId: '40', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '41', + trainId: '41', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '42', + trainId: '42', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '43', + trainId: '43', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '44', + trainId: '44', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '45', + trainId: '45', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '46', + trainId: '46', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, { - id: '47', + trainId: '47', trackId: '4', arrivalTrainName: '643152', departureTrainName: '643153', color: 'rgb(121, 118, 113)', - arrivalTime: new Date('2024/04/02 00:05'), + arrivalTime: new Date('2024/04/02 00:05').getTime(), originStation: 'FOO', destinationStation: 'FOO', - departureTime: new Date('2024/04/02 00:10'), + departureTime: new Date('2024/04/02 00:10').getTime(), }, ]; -export default OccupancyZones; +export default OCCUPANCY_ZONES; diff --git a/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx index c55422f7087..f5348a283ca 100755 --- a/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx @@ -1,286 +1,38 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; + +import { TrackOccupancyStandalone } from '@osrd-project/ui-charts'; import type { Meta, StoryObj } from '@storybook/react'; -import occupancyZones from './assets/occupancyZones'; -import tracks from './assets/tracks'; -import { TimeCaptions } from '../../../../ui-charts/src/spaceTimeChart/components/TimeCaptions'; -import { useCanvas, useDraw } from '../../../../ui-charts/src/spaceTimeChart/hooks/useCanvas'; -import { useMouseInteractions } from '../../../../ui-charts/src/spaceTimeChart/hooks/useMouseInteractions'; -import { useMouseTracking } from '../../../../ui-charts/src/spaceTimeChart/hooks/useMouseTracking'; -import { useSize } from '../../../../ui-charts/src/spaceTimeChart/hooks/useSize'; -import { DEFAULT_THEME } from '../../../../ui-charts/src/spaceTimeChart/lib/consts'; -import { - CanvasContext, - MouseContext, - SpaceTimeChartContext, -} from '../../../../ui-charts/src/spaceTimeChart/lib/context'; -import type { - MouseContextType, - SpaceTimeChartContextType, - PickingElement, - SpaceTimeChartTheme, -} from '../../../../ui-charts/src/spaceTimeChart/lib/types'; -import { - getTimeToPixel, - getSpaceToPixel, - getDataToPoint, - getPixelToTime, - getPixelToSpace, - getPointToData, - spaceScalesToBinaryTree, -} from '../../../../ui-charts/src/spaceTimeChart/utils/scales'; -import { - TrackOccupancyManchette, - TrackOccupancyCanvas, -} from '../../../../ui-charts/src/trackOccupancyDiagram/index'; -import { KebabHorizontal } from '../../../../ui-icons/src/index'; -import { OPERATIONAL_POINTS } from '../spaceTimeChart/helpers/paths'; +import OCCUPANCY_ZONES from './assets/occupancyZones'; +import { TRACKS } from '../manchetteWithSpaceTimeChart/assets/trackOccupancyData'; -type TrackOccupancyDiagramProps = { - xZoomLevel: number; - yZoomLevel: number; - xOffset: number; - yOffset: number; - spaceScaleType: 'linear' | 'proportional'; - emptyData: boolean; - selectedTrainId: string; - setSelectedTrainId: (id: string) => void; -}; +import './styles/track-occupancy.css'; -const OP_ID = 'story'; -const X_ZOOM_LEVEL = 6; -const Y_ZOOM_LEVEL = 3; const SELECTED_TRAIN_ID = '5'; -const TrackOccupancyDiagram = ({ - xZoomLevel, - yZoomLevel, - xOffset, - yOffset, - spaceScaleType, - emptyData, - selectedTrainId, - setSelectedTrainId, -}: TrackOccupancyDiagramProps) => { - const spaceOrigin = 0; - const [root, setRoot] = useState(null); - const { width, height } = useSize(root); - const [canvasesRoot, setCanvasesRoot] = useState(null); - const { width: trackOccupancyWidth, height: trackOccupancyHeight } = useSize(canvasesRoot); - const timeOrigin = +new Date('2024/04/02'); - const timeScale = 60000 / xZoomLevel; - const swapAxis = undefined; - const hideGrid = undefined; - const hidePathsLabels = undefined; - const enableSnapping = undefined; - const showTicks = true; - const fullTheme: SpaceTimeChartTheme = { - ...DEFAULT_THEME, - background: 'transparent', - timeGraduationsStyles: { - ...DEFAULT_THEME.timeGraduationsStyles, - 1: { ...DEFAULT_THEME.timeGraduationsStyles[1], color: 'transparent' }, - }, - }; - const operationalPoints = useMemo(() => (emptyData ? [] : OPERATIONAL_POINTS), [emptyData]); - const spaceScales = useMemo(() => { - if (emptyData) { - return []; - } - - return operationalPoints.slice(0, -1).map((point, i) => ({ - from: point.position, - to: operationalPoints[i + 1].position, - ...(spaceScaleType === 'linear' - ? { size: 50 * yZoomLevel } - : { coefficient: 150 / yZoomLevel }), - })); - }, [emptyData, operationalPoints, spaceScaleType, yZoomLevel]); - - const fingerprint = useMemo( - () => - JSON.stringify({ - width, - height, - spaceOrigin, - spaceScales, - timeOrigin, - timeScale, - xOffset, - yOffset, - swapAxis, - hideGrid, - hidePathsLabels, - showTicks, - }), - [ - width, - height, - spaceOrigin, - spaceScales, - timeOrigin, - timeScale, - xOffset, - yOffset, - swapAxis, - hideGrid, - hidePathsLabels, - showTicks, - ] - ); - - // TODO: when occupancyZones layer and zoom/pan are implemented, clean all unneeded variables from contextState, variables declared before contextState, and props. If needed, create a new context type. - const contextState: SpaceTimeChartContextType = useMemo(() => { - const spaceScaleTree = spaceScalesToBinaryTree(spaceOrigin, spaceScales); - const timeAxis = !swapAxis ? 'x' : 'y'; - const spaceAxis = !swapAxis ? 'y' : 'x'; - - // Data translation helpers: - let timePixelOffset; - let spacePixelOffset; - - if (!swapAxis) { - timePixelOffset = xOffset; - spacePixelOffset = yOffset; - } else { - timePixelOffset = yOffset; - spacePixelOffset = xOffset; - } - - const getTimePixel = getTimeToPixel(timeOrigin, timePixelOffset, timeScale); - const getSpacePixel = getSpaceToPixel(spacePixelOffset, spaceScaleTree); - const getPoint = getDataToPoint(getTimePixel, getSpacePixel, timeAxis, spaceAxis); - const getTime = getPixelToTime(timeOrigin, timePixelOffset, timeScale); - const getSpace = getPixelToSpace(spacePixelOffset, spaceScaleTree); - const getData = getPointToData(getTime, getSpace, timeAxis, spaceAxis); - - const pickingElements: PickingElement[] = []; - const resetPickingElements = () => { - pickingElements.length = 0; - }; - const registerPickingElement = (element: PickingElement) => { - pickingElements.push(element); - return pickingElements.length - 1; - }; - - return { - fingerprint, - width, - height, - trackOccupancyHeight, - trackOccupancyWidth, - getTimePixel, - getSpacePixel, - getPoint, - getTime, - getSpace, - getData, - pickingElements, - resetPickingElements, - registerPickingElement, - operationalPoints, - tracks, - occupancyZones, - spaceOrigin, - spaceScaleTree, - timeOrigin, - timeScale, - timePixelOffset, - spacePixelOffset, - timeAxis, - spaceAxis, - swapAxis: !!swapAxis, - enableSnapping: !!enableSnapping, - hideGrid: !!hideGrid, - hidePathsLabels: !!hidePathsLabels, - showTicks: !!showTicks, - theme: fullTheme, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fingerprint]); - - const [spaceTicksRoot, setSpaceTicksRoot] = useState(null); - - const mouseState = useMouseTracking(root); - const { position } = mouseState; - const { canvasContext } = useCanvas(canvasesRoot, contextState, position); - const { canvasContext: spaceTicksContext } = useCanvas(spaceTicksRoot, contextState, position); - - const mouseContext = useMemo( - () => ({ - isHover: false, - position: mouseState.position, - hoveredItem: null, - data: contextState.getData(mouseState.position), - }), - [mouseState.position, contextState] - ); - - const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); - - const onClick = () => { - setMousePosition(mouseContext.position); - }; - - useMouseInteractions(canvasesRoot, mouseContext, { onClick }, contextState); - return ( -
- -
-
- -
-
-
- -
- - -
- -
-
-
-
-
- -
-
- -
-
-
-
-
- ); -}; - -const TrackOccupancyDiagramStory = ({ trainId }: { trainId: number }) => { - const [selectedTrainId, setSelectedTrainId] = useState('0'); +const TrackOccupancyDiagramStory = ({ + trainId, + autoHeight, +}: { + trainId: number; + autoHeight?: boolean; +}) => { + const [selectedTrainId, setSelectedTrainId] = useState(undefined); useEffect(() => { setSelectedTrainId(`${trainId}`); }, [trainId]); return ( - +
+ +
); }; @@ -313,5 +65,6 @@ type Story = StoryObj; export const TrackOccupancyDiagramStoryDefault: Story = { args: { trainId: 5, + autoHeight: false, }, }; diff --git a/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/styles/track-occupancy.css b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/styles/track-occupancy.css new file mode 100644 index 00000000000..fe5082e0119 --- /dev/null +++ b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/styles/track-occupancy.css @@ -0,0 +1,3 @@ +#track-occupancy-diagram-base-story { + padding: 50px; +} diff --git a/front/ui/storybook/styles/global.css b/front/ui/storybook/styles/global.css deleted file mode 100644 index fc3982cd408..00000000000 --- a/front/ui/storybook/styles/global.css +++ /dev/null @@ -1 +0,0 @@ -@import './trackOccupancyDiagram/baseStory.css'; diff --git a/front/ui/storybook/styles/trackOccupancyDiagram/baseStory.css b/front/ui/storybook/styles/trackOccupancyDiagram/baseStory.css deleted file mode 100644 index e35b00146f1..00000000000 --- a/front/ui/storybook/styles/trackOccupancyDiagram/baseStory.css +++ /dev/null @@ -1,39 +0,0 @@ -#track-occupancy-diagram-base-story { - padding: 30px 40px; - - .main-container { - width: 1424px; - box-shadow: - 0px 2px 4px 0 rgba(0, 0, 0, 0.22), - 0 4px 7px -3px rgba(255, 171, 88, 0.17), - inset 0 1px 0 0 rgb(255, 255, 255); - border-radius: 10px; - - &-header { - height: 40px; - width: 100%; - padding-left: 16px; - border-radius: 10px 10px 0 0; - box-shadow: - inset 0 1px 0 0 rgb(255, 255, 255), - inset 0 -1px 0 0 rgba(0, 0, 0, 0.25); - } - - &-manchette { - width: 200px; - border-radius: 0 0 0 10px; - } - - &-canvas { - width: 1224px; - border-radius: 0 0 10px 0; - position: relative; - } - - &-time-captions { - width: 1224px; - height: 33px; - margin-left: 200px; - } - } -} diff --git a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts index 11ecacdd356..ce008a1e80f 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts @@ -1,7 +1,5 @@ import { type HTMLProps, type ReactNode } from 'react'; -import type { Track, OccupancyZone } from '../../trackOccupancyDiagram/components/types'; - // GLOBAL UTILITY TYPES: export type Point = { x: number; @@ -248,8 +246,6 @@ export type SpaceTimeChartProps = { export type SpaceTimeChartContextType = { width: number; height: number; - trackOccupancyWidth?: number; - trackOccupancyHeight?: number; // Axis-swapping related data: timeAxis: Axis; @@ -283,8 +279,6 @@ export type SpaceTimeChartContextType = { // Useful data: operationalPoints: OperationalPoint[]; - tracks?: Track[]; - occupancyZones?: OccupancyZone[]; // Full theme: theme: SpaceTimeChartTheme; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx index 36a243754e9..a64ecd78347 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx @@ -2,29 +2,28 @@ import React from 'react'; import OccupancyZonesLayer from './layers/OccupancyZonesLayer'; import TracksLayer from './layers/TracksLayer'; -import { type TrackOccupancyCanvasProps } from './types'; +import type { OccupancyZone, Track } from './types'; const TrackOccupancyCanvas = ({ - opId, - useDraw, - setCanvasesRoot, + position, + tracks, + occupancyZones, selectedTrainId, - setSelectedTrainId, - mousePosition, -}: TrackOccupancyCanvasProps) => ( -
- +}: { + position: number; + tracks: Track[]; + occupancyZones: OccupancyZone[]; + selectedTrainId?: string; +}) => ( + <> + -
+ ); export default TrackOccupancyCanvas; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx index 4e059d0f981..44d6ba7eb29 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { TRACK_HEIGHT_CONTAINER } from './consts'; -import { type TrackOccupancyManchetteProps } from './types'; +import type { Track } from './types'; -const TrackOccupancyManchette = ({ tracks }: TrackOccupancyManchetteProps) => ( -
+const TrackOccupancyManchette = ({ tracks }: { tracks: Track[] }) => ( +
{tracks.map((track) => ( // height is shared between manchette and canvas components
diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx new file mode 100644 index 00000000000..790961802e4 --- /dev/null +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx @@ -0,0 +1,105 @@ +import React, { useMemo, useRef } from 'react'; + +import { KebabHorizontal } from '@osrd-project/ui-icons'; + +import { TRACK_HEIGHT_CONTAINER } from './consts'; +import TrackOccupancyCanvas from './TrackOccupancyCanvas'; +import TrackOccupancyManchette from './TrackOccupancyManchette'; +import type { OccupancyZone, Track } from './types'; +import { Manchette, useManchetteWithSpaceTimeChart } from '../../manchette'; +import { SpaceTimeChart } from '../../spaceTimeChart'; +import { HOUR } from '../../spaceTimeChart/lib/consts'; + +const TrackOccupancyStandalone = ({ + tracks, + occupancyZones, + selectedTrainId, + height = TRACK_HEIGHT_CONTAINER * tracks.length, +}: { + tracks: Track[]; + occupancyZones: OccupancyZone[]; + selectedTrainId?: string; + height?: number; +}) => { + const manchetteWithSpaceTimeChartRef = useRef(null); + const spaceTimeChartRef = useRef(null); + const defaultTimeOrigin = useMemo(() => { + const minTime = Math.min(...(occupancyZones.map((zone) => zone.arrivalTime) || Date.now())); + // Take first round hour before minTime: + return Math.floor(minTime / HOUR) * HOUR; + }, [occupancyZones]); + + // To make SpaceTimeChart and Manchette work, we have to provide them some dummy data: + const waypoints = useMemo( + () => [ + { + id: 'FAKE_WAYPOINT_1', + position: 0, + }, + ], + [] + ); + const splitPoints = useMemo( + () => [ + { + id: 'ACTUAL_TRACK_OCCUPANCY_DIAGRAM', + position: 0, + size: Math.max(height, tracks.length * TRACK_HEIGHT_CONTAINER), + spaceTimeChartNode: ( + + ), + manchetteNode: , + }, + ], + [height, tracks, occupancyZones, selectedTrainId] + ); + + /** + * We now use useManchetteWithSpaceTimeChart, to get proper pan along the time (and space axis if + * the container is smaller than the contents): + */ + const { manchetteProps, spaceTimeChartProps, handleScroll } = useManchetteWithSpaceTimeChart({ + waypoints, + manchetteWithSpaceTimeChartRef, + height, + spaceTimeChartRef, + splitPoints, + defaultTimeOrigin, + verticalPadding: 0, + options: { + displayTimeCaptions: false, + enableTimePan: true, + enableSpacePan: true, + enableTimeZoom: false, + }, + }); + + return ( +
+
+ {/* TODO: Bind actions? */} + +
+
+
+ +
+ +
+
+
+
+ ); +}; + +export default TrackOccupancyStandalone; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/consts.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/consts.ts index 843abc0b094..217d161d14b 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/consts.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/consts.ts @@ -15,7 +15,9 @@ export const COLORS = { GREY_50: 'rgb(121, 118, 113)', GREY_60: 'rgb(92, 89, 85)', GREY_80: 'rgb(49, 46, 43)', - HOUR_BACKGROUND: 'rgba(243, 248, 253, 0.5)', + MANCHETTE_BACKGROUND: '#f2f0e4', + HOUR_BACKGROUND_1: '#faf9f5', + HOUR_BACKGROUND_2: '#f2f0e4', RAIL_TICK: 'rgb(33, 112, 185)', REMAINING_TRAINS_BACKGROUND: 'rgba(0, 0, 0, 0.7)', SELECTION_20: 'rgb(255, 242, 179)', diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts index 0af47d500ee..d4ed250a228 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts @@ -1,4 +1,5 @@ import { drawOccupancyZonesTexts } from './drawOccupancyZonesTexts'; +import type { SpaceTimeChartContextType } from '../../../../spaceTimeChart'; import { TRACK_HEIGHT_CONTAINER, CANVAS_PADDING, @@ -21,21 +22,12 @@ const X_TROUGHTRAIN_BACKGROUND_PADDING = 8; const BACKGROUND_HEIGHT = 40; const SELECTED_TRAIN_ID_GRADIANT = 2; -type DrawZone = { - ctx: CanvasRenderingContext2D; - arrivalTimePixel: number; - departureTimePixel: number; - yPosition: number; -}; - -const drawDefaultZone = ({ ctx, arrivalTimePixel, departureTimePixel, yPosition }: DrawZone) => { +const drawDefaultZone = ( + ctx: CanvasRenderingContext2D, + { x, y, width }: { x: number; y: number; width: number } +) => { ctx.beginPath(); - ctx.rect( - arrivalTimePixel, - yPosition, - departureTimePixel - arrivalTimePixel, - OCCUPANCY_ZONE_HEIGHT - ); + ctx.rect(x, y, width, OCCUPANCY_ZONE_HEIGHT); ctx.fill(); ctx.stroke(); }; @@ -46,10 +38,7 @@ const ARROW_WIDTH = 4.5; const ARROW_TOP_Y = 3.5; const ARROW_BOTTOM_Y = 6.5; -const drawThroughTrain = ({ - ctx, - arrivalTimePixel, -}: Omit) => { +const drawThroughTrain = (ctx: CanvasRenderingContext2D, x: number, y: number) => { // Through trains are materialized by converging arrows like the following ones // ___ // \_/ @@ -57,29 +46,33 @@ const drawThroughTrain = ({ // ‾‾‾ ctx.beginPath(); // draw the upper part - ctx.moveTo(arrivalTimePixel - ARROW_OFFSET_X, OCCUPANCY_ZONE_Y_START + ARROW_OFFSET_Y); - ctx.lineTo(arrivalTimePixel - ARROW_WIDTH, OCCUPANCY_ZONE_Y_START - ARROW_TOP_Y); - ctx.lineTo(arrivalTimePixel + ARROW_WIDTH, OCCUPANCY_ZONE_Y_START - ARROW_TOP_Y); - ctx.lineTo(arrivalTimePixel + ARROW_OFFSET_X, OCCUPANCY_ZONE_Y_START + ARROW_OFFSET_Y); + ctx.moveTo(x - ARROW_OFFSET_X, y + ARROW_OFFSET_Y); + ctx.lineTo(x - ARROW_WIDTH, y - ARROW_TOP_Y); + ctx.lineTo(x + ARROW_WIDTH, y - ARROW_TOP_Y); + ctx.lineTo(x + ARROW_OFFSET_X, y + ARROW_OFFSET_Y); // draw the lower part - ctx.lineTo(arrivalTimePixel + ARROW_WIDTH, OCCUPANCY_ZONE_Y_START + ARROW_BOTTOM_Y); - ctx.lineTo(arrivalTimePixel - ARROW_WIDTH, OCCUPANCY_ZONE_Y_START + ARROW_BOTTOM_Y); - ctx.lineTo(arrivalTimePixel - ARROW_OFFSET_X, OCCUPANCY_ZONE_Y_START + ARROW_OFFSET_Y); + ctx.lineTo(x + ARROW_WIDTH, y + ARROW_BOTTOM_Y); + ctx.lineTo(x - ARROW_WIDTH, y + ARROW_BOTTOM_Y); + ctx.lineTo(x - ARROW_OFFSET_X, y + ARROW_OFFSET_Y); ctx.fill(); // draw the white separator in the middle - ctx.moveTo(arrivalTimePixel - ARROW_OFFSET_X, OCCUPANCY_ZONE_Y_START + ARROW_OFFSET_Y); - ctx.lineTo(arrivalTimePixel + ARROW_OFFSET_X, OCCUPANCY_ZONE_Y_START + ARROW_OFFSET_Y); + ctx.moveTo(x - ARROW_OFFSET_X, y + ARROW_OFFSET_Y); + ctx.lineTo(x + ARROW_OFFSET_X, y + ARROW_OFFSET_Y); ctx.stroke(); }; -type DrawRemainingTrainsBox = { +const drawRemainingTrainsBox = ({ + ctx, + remainingTrainsNb, + xPosition, + yPosition, +}: { ctx: CanvasRenderingContext2D; remainingTrainsNb: number; xPosition: number; -}; - -const drawRemainingTrainsBox = ({ ctx, remainingTrainsNb, xPosition }: DrawRemainingTrainsBox) => { - const textY = OCCUPANCY_ZONE_Y_START - REMAINING_TEXT_OFFSET; + yPosition: number; +}) => { + const textY = yPosition + OCCUPANCY_ZONE_Y_START - REMAINING_TEXT_OFFSET; ctx.fillStyle = REMAINING_TRAINS_BACKGROUND; ctx.beginPath(); @@ -97,53 +90,22 @@ const drawRemainingTrainsBox = ({ ctx, remainingTrainsNb, xPosition }: DrawRemai ); }; -const drawOccupationZone = ({ - ctx, - zone, - tracks, - arrivalTimePixel, - departureTimePixel, - yPosition, - isThroughTrain, - selectedTrainId, - setSelectedTrainId, - xMousePosition, - yMousePosition, - index, -}: { - ctx: CanvasRenderingContext2D; - zone: OccupancyZone; - tracks: Track[]; - arrivalTimePixel: number; - departureTimePixel: number; - yPosition: number; - isThroughTrain: boolean; - selectedTrainId: string; - setSelectedTrainId: (id: string) => void; - index: number; - xMousePosition: number; - yMousePosition: number; -}) => { - let currentSelectedTrainId = selectedTrainId; - - const trackN = CANVAS_PADDING + TRACK_HEIGHT_CONTAINER * index + yPosition; - const canvasHeight = CANVAS_PADDING * 2 + TRACK_HEIGHT_CONTAINER * tracks.length; - const trackPosition = canvasHeight - trackN; - - const arrowOffset = isThroughTrain ? 4 : 0; - - const xCheck = - xMousePosition >= arrivalTimePixel - arrowOffset && - xMousePosition <= departureTimePixel + arrowOffset; - - const yCheck = - Math.abs(yMousePosition) >= trackPosition - OCCUPANCY_ZONE_HEIGHT - 1 - arrowOffset && - Math.abs(yMousePosition) <= trackPosition + 1 + arrowOffset; - - if (xCheck && yCheck) { - setSelectedTrainId(zone.id); - currentSelectedTrainId = zone.id; +const drawOccupationZone = ( + ctx: CanvasRenderingContext2D, + stcContext: SpaceTimeChartContextType, + { + zone, + position, + yZone, + selectedTrainId, + }: { + zone: OccupancyZone; + position: number; + yZone: number; + selectedTrainId?: string; } +) => { + const isThroughTrain = zone.arrivalTime === zone.departureTime; ctx.fillStyle = zone.color; ctx.strokeStyle = WHITE_100; @@ -151,17 +113,22 @@ const drawOccupationZone = ({ ctx.lineCap = 'round'; ctx.font = '400 10px IBM Plex Mono'; - if (selectedTrainId === zone.id) { + const { getTimePixel, getSpacePixel } = stcContext; + const yStart = getSpacePixel(position); + const yEnd = getSpacePixel(position, true); + const arrivalTimePixel = getTimePixel(zone.arrivalTime); + const departureTimePixel = getTimePixel(zone.departureTime); + + if (selectedTrainId === zone.trainId) { const extraWidth = isThroughTrain ? X_TROUGHTRAIN_BACKGROUND_PADDING : X_BACKGROUND_PADDING; const originTextLength = ctx.measureText(zone.originStation || '--').width; const destinationTextLength = ctx.measureText(zone.destinationStation || '--').width; - ctx.save(); ctx.fillStyle = SELECTION_20; ctx.beginPath(); ctx.roundRect( arrivalTimePixel - originTextLength - extraWidth, - yPosition - BACKGROUND_HEIGHT / 2, + yZone - BACKGROUND_HEIGHT / 2, departureTimePixel - arrivalTimePixel + originTextLength + @@ -171,67 +138,78 @@ const drawOccupationZone = ({ SELECTED_TRAIN_ID_GRADIANT ); ctx.fill(); - ctx.restore(); } if (isThroughTrain) { - drawThroughTrain({ ctx, arrivalTimePixel }); + drawThroughTrain(ctx, arrivalTimePixel, yZone); } else { - drawDefaultZone({ ctx, arrivalTimePixel, departureTimePixel, yPosition }); + drawDefaultZone(ctx, { + x: arrivalTimePixel, + y: yZone, + width: departureTimePixel - arrivalTimePixel, + }); + } + + // Draw trains: + ctx.strokeStyle = zone.color; + ctx.lineWidth = 1; + ctx.setLineDash([1, 4]); + if (zone.arrivalDirection) { + ctx.beginPath(); + ctx.moveTo(arrivalTimePixel, yZone); + ctx.lineTo(arrivalTimePixel, zone.arrivalDirection === 'up' ? yStart : yEnd); + ctx.stroke(); + } + if (zone.departureDirection) { + ctx.beginPath(); + ctx.moveTo(departureTimePixel, yZone); + ctx.lineTo(departureTimePixel, zone.departureDirection === 'up' ? yStart : yEnd); + ctx.stroke(); } + ctx.setLineDash([]); + // Draw texts: drawOccupancyZonesTexts({ ctx, zone, arrivalTimePixel, departureTimePixel, - yPosition, isThroughTrain, - selectedTrainId: currentSelectedTrainId, + selectedTrainId, + yPosition: yZone, }); }; -export const drawOccupancyZones = ({ - ctx, - width, - height, - tracks, - occupancyZones, - getTimePixel, - selectedTrainId, - setSelectedTrainId, - mousePosition, -}: { - ctx: CanvasRenderingContext2D; - width: number; - height: number; - tracks: Track[] | undefined; - occupancyZones: OccupancyZone[] | undefined; - getTimePixel: (time: number) => number; - selectedTrainId: string; - setSelectedTrainId: (id: string) => void; - mousePosition: { x: number; y: number }; -}) => { - ctx.clearRect(0, 0, width, height); - ctx.save(); - +export const drawOccupancyZones = ( + ctx: CanvasRenderingContext2D, + stcContext: SpaceTimeChartContextType, + { + occupancyZones, + tracks, + position, + selectedTrainId, + }: { + occupancyZones: OccupancyZone[]; + tracks: Track[]; + position: number; + selectedTrainId?: string; + } +) => { if (!tracks || !occupancyZones || occupancyZones.length === 0) return; - const sortedOccupancyZones = occupancyZones.sort( - (a, b) => a.arrivalTime.getTime() - b.arrivalTime.getTime() - ); + const { getTimePixel, getSpacePixel } = stcContext; + const baseY = getSpacePixel(position); - tracks.forEach((track, index) => { - const trackTranslate = index === 0 ? CANVAS_PADDING : TRACK_HEIGHT_CONTAINER; - ctx.translate(0, trackTranslate); + const sortedOccupancyZones = occupancyZones.sort((a, b) => a.arrivalTime - b.arrivalTime); - const { x: xMousePosition, y: yMousePosition } = mousePosition; + tracks.forEach((track, index) => { + const trackY = baseY + CANVAS_PADDING + index * TRACK_HEIGHT_CONTAINER; const filteredOccupancyZones = sortedOccupancyZones.filter((zone) => zone.trackId === track.id); - let primaryArrivalTimePixel = 0; - let primaryDepartureTimePixel = 0; - let lastDepartureTimePixel = primaryDepartureTimePixel; + let primaryArrivalTime = 0; + let primaryDepartureTime = 0; + let lastDepartureTime = primaryDepartureTime; let yPosition = OCCUPANCY_ZONE_Y_START; let yOffset = Y_OFFSET_INCREMENT; let zoneCounter = 0; @@ -239,9 +217,7 @@ export const drawOccupancyZones = ({ while (zoneIndex < filteredOccupancyZones.length) { const zone = filteredOccupancyZones[zoneIndex]; - const arrivalTimePixel = getTimePixel(zone.arrivalTime.getTime()); - const departureTimePixel = getTimePixel(zone.departureTime.getTime()); - const isThroughTrain = arrivalTimePixel === departureTimePixel; + const { arrivalTime, departureTime } = zone; // * if the zone is not overlapping with any previous one, draw it in the center of the track // * and reset the primary values @@ -252,28 +228,20 @@ export const drawOccupancyZones = ({ // * if the zone is overlapping with the previous one and the counter is higher than the max zones // * draw the remaining trains box // * - if (arrivalTimePixel > lastDepartureTimePixel) { + if (arrivalTime > lastDepartureTime) { // reset to initial value if the zone is not overlapping yPosition = OCCUPANCY_ZONE_Y_START; - primaryArrivalTimePixel = arrivalTimePixel; - primaryDepartureTimePixel = departureTimePixel; - lastDepartureTimePixel = departureTimePixel; + primaryArrivalTime = arrivalTime; + primaryDepartureTime = departureTime; + lastDepartureTime = departureTime; yOffset = Y_OFFSET_INCREMENT; zoneCounter = 1; - drawOccupationZone({ - ctx, + drawOccupationZone(ctx, stcContext, { zone, - tracks, - arrivalTimePixel, - departureTimePixel, - yPosition, - isThroughTrain, + position, selectedTrainId, - setSelectedTrainId, - index, - xMousePosition, - yMousePosition, + yZone: trackY + yPosition, }); zoneIndex++; @@ -283,7 +251,7 @@ export const drawOccupancyZones = ({ if (zoneCounter < MAX_ZONES) { // if so and it's an even index, move it to the bottom, if it's an odd index, move it to the top - if (arrivalTimePixel >= primaryArrivalTimePixel) { + if (arrivalTime >= primaryArrivalTime) { if (zoneCounter % 2 === 0) { yPosition -= yOffset; } else { @@ -292,22 +260,13 @@ export const drawOccupancyZones = ({ } // update the last departure time if the current zone is longer - if (departureTimePixel >= lastDepartureTimePixel) - lastDepartureTimePixel = departureTimePixel; + if (departureTime >= lastDepartureTime) lastDepartureTime = departureTime; - drawOccupationZone({ - ctx, + drawOccupationZone(ctx, stcContext, { zone, - tracks, - arrivalTimePixel, - departureTimePixel, - yPosition, - isThroughTrain, + position, + yZone: trackY + yPosition, selectedTrainId, - setSelectedTrainId, - index, - xMousePosition, - yMousePosition, }); zoneCounter++; @@ -318,22 +277,17 @@ export const drawOccupancyZones = ({ } const nextIndex = filteredOccupancyZones.findIndex( - (filteredZone, i) => - i > zoneIndex && - getTimePixel(filteredZone.arrivalTime.getTime()) >= lastDepartureTimePixel + (filteredZone, i) => i > zoneIndex && filteredZone.arrivalTime >= lastDepartureTime ); const remainingTrainsNb = nextIndex - zoneIndex; const xPosition = - primaryArrivalTimePixel + - (lastDepartureTimePixel - primaryArrivalTimePixel) / 2 - - REMAINING_TRAINS_WIDTH / 2; + getTimePixel((primaryArrivalTime + lastDepartureTime) / 2) - REMAINING_TRAINS_WIDTH / 2; - drawRemainingTrainsBox({ ctx, remainingTrainsNb, xPosition }); + drawRemainingTrainsBox({ ctx, remainingTrainsNb, xPosition, yPosition: trackY }); zoneIndex += remainingTrainsNb; } }); - ctx.restore(); }; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts index 72e6eac45b3..d13fb4035ba 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts @@ -36,7 +36,7 @@ export const drawOccupancyZonesTexts = ({ departureTimePixel: number; yPosition: number; isThroughTrain: boolean; - selectedTrainId: string; + selectedTrainId?: string; }) => { const zoneOccupancyLength = departureTimePixel - arrivalTimePixel - STROKE_WIDTH; @@ -66,12 +66,12 @@ export const drawOccupancyZonesTexts = ({ const xDeparturePosition = isBelowBreakpoint('small') ? 'left' : 'center'; const textStroke = { - color: selectedTrainId === zone.id ? 'transparent' : WHITE_100, + color: selectedTrainId === zone.trainId ? 'transparent' : WHITE_100, width: STROKE_WIDTH, }; // train name - if (selectedTrainId === zone.id) { + if (selectedTrainId === zone.trainId) { const { xSelectedTrainNameBackground, ySelectedTrainNameBackground } = isBelowBreakpoint( 'medium' ) @@ -115,7 +115,9 @@ export const drawOccupancyZonesTexts = ({ // arrival minutes & departure minutes drawText({ ctx, - text: zone.arrivalTime.getMinutes().toLocaleString('fr-FR', { minimumIntegerDigits: 2 }), + text: new Date(zone.arrivalTime) + .getMinutes() + .toLocaleString('fr-FR', { minimumIntegerDigits: 2 }), x: isThroughTrain ? arrivalTimePixel - X_THROUGHTRAIN_OFFSET : arrivalTimePixel, y: yPosition + MINUTES_TEXT_OFFSET, color: GREY_80, @@ -128,7 +130,9 @@ export const drawOccupancyZonesTexts = ({ if (!isThroughTrain) drawText({ ctx, - text: zone.departureTime.getMinutes().toLocaleString('fr-FR', { minimumIntegerDigits: 2 }), + text: new Date(zone.departureTime) + .getMinutes() + .toLocaleString('fr-FR', { minimumIntegerDigits: 2 }), x: departureTimePixel, y: yPosition + MINUTES_TEXT_OFFSET, color: GREY_80, diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTrack.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTrack.ts index 633d8a7baff..dc08ce05701 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTrack.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTrack.ts @@ -3,7 +3,7 @@ import { sum } from 'lodash'; import { TRACK_HEIGHT_CONTAINER, COLORS, TICKS_PATTERN } from '../../consts'; import { getTickPattern } from '../../utils'; -const { WHITE_50, GREY_20, RAIL_TICK } = COLORS; +const { WHITE_100, WHITE_50, GREY_20, RAIL_TICK } = COLORS; const drawRails = ({ xStart, @@ -18,10 +18,12 @@ const drawRails = ({ stroke?: string; ctx: CanvasRenderingContext2D; }) => { - ctx.clearRect(xStart, yStart, width, 9); + ctx.fillStyle = WHITE_100; + ctx.fillRect(xStart, yStart, width, 9); ctx.fillStyle = WHITE_50; ctx.strokeStyle = stroke; + ctx.lineWidth = 1; ctx.beginPath(); ctx.rect(xStart, yStart, width, 8); ctx.fill(); @@ -44,6 +46,7 @@ const drawTick = ({ const sumTicks = sum(ticks) / 2; ctx.strokeStyle = stroke; + ctx.lineWidth = 1; ctx.beginPath(); ctx.setLineDash(ticks); ctx.moveTo(xStart, yStart - sumTicks); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts index f184a05572d..a2444159bbc 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts @@ -1,90 +1,48 @@ import { drawTrack } from './drawTrack'; +import type { SpaceTimeChartContextType } from '../../../../spaceTimeChart'; +import { HOUR, MINUTE } from '../../../../spaceTimeChart/lib/consts'; import { TRACK_HEIGHT_CONTAINER, CANVAS_PADDING, COLORS, TICKS_PRIORITIES } from '../../consts'; import { type Track } from '../../types'; import { getLabelLevels, getLabelMarks } from '../../utils'; -export function getTimeToPixel( - timeOrigin: number, - pixelOffset: number, - timeScale: number -): (time: number) => number { - return (time: number) => pixelOffset + (time - timeOrigin) / timeScale; -} - -const { WHITE_100, HOUR_BACKGROUND } = COLORS; - -const drawBackground = ({ - ctx, - xStart, - width, - height, - switchBackground, -}: { - ctx: CanvasRenderingContext2D; - xStart: number; - width: number; - height: number; - switchBackground: boolean; -}) => { - if (xStart >= 0) { - ctx.clearRect(xStart, 0, width, height); - ctx.fillStyle = switchBackground ? HOUR_BACKGROUND : WHITE_100; - ctx.fillRect(xStart, 0, width, height); - } -}; - -type DrawTracksProps = { - ctx: CanvasRenderingContext2D; - width: number; - height: number; - tracks: Track[] | undefined; - timeOrigin: number; - timePixelOffset: number; - getTimePixel: (time: number) => number; - timeRanges: number[]; - breakpoints: number[]; - timeScale: number; -}; - -export const drawTracks = ({ - ctx, - width, - height, - tracks, - timeOrigin, - timePixelOffset, - getTimePixel, - timeRanges, - breakpoints, - timeScale, -}: DrawTracksProps) => { - ctx.clearRect(0, 0, width, height); - ctx.save(); - - const minT = timeOrigin - timeScale * timePixelOffset; - const maxT = minT + timeScale * width; - const pixelsPerMinute = (1 / timeScale) * 60_000; +const { HOUR_BACKGROUND_1, HOUR_BACKGROUND_2 } = COLORS; + +export const drawTracks = ( + ctx: CanvasRenderingContext2D, + stcContext: SpaceTimeChartContextType, + position: number, + tracks: Track[] +) => { + const { + width, + getSpacePixel, + getTime, + getTimePixel, + timeScale, + theme: { breakpoints, timeRanges }, + } = stcContext; + const yStart = getSpacePixel(position); + const yEnd = getSpacePixel(position, true); + const height = yEnd - yStart; + const timeStart = getTime(0); + const timeEnd = getTime(width); + const pixelsPerMinute = (1 / timeScale) * MINUTE; const labelLevels = getLabelLevels(breakpoints, pixelsPerMinute, TICKS_PRIORITIES); - - const labelMarks = getLabelMarks(timeRanges, minT, maxT, labelLevels); - - let switchBackground = false; - - for (const t in labelMarks) { - const date = new Date(+t); - const minutes = date.getMinutes().toString().padStart(2, '0'); - - switch (minutes) { - case '00': - switchBackground = !switchBackground; - drawBackground({ ctx, xStart: getTimePixel(+t), width, height, switchBackground }); - break; - default: - break; - } + const labelMarks = getLabelMarks(timeRanges, timeStart, timeEnd, labelLevels); + + let hours = Math.floor(timeStart / HOUR); + const hourEnd = timeEnd / HOUR; + while (hours < hourEnd) { + const x = getTimePixel(hours * HOUR); + const w = getTimePixel((hours + 1) * HOUR) - x; + ctx.fillStyle = hours % 2 ? HOUR_BACKGROUND_1 : HOUR_BACKGROUND_2; + ctx.fillRect(x, yStart, w, height); + hours++; } + ctx.save(); + ctx.translate(0, yStart); tracks?.forEach((_, index) => { const trackTranslate = index === 0 ? CANVAS_PADDING : TRACK_HEIGHT_CONTAINER; ctx.translate(0, trackTranslate); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx index fabc0ada057..25dc325ef4e 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx @@ -1,38 +1,33 @@ import { useCallback } from 'react'; -import { type LayerType, type DrawingFunction } from '../../../spaceTimeChart/lib/types'; +import { useDraw, type DrawingFunction } from '../../../spaceTimeChart'; import { drawOccupancyZones } from '../helpers/drawElements/drawOccupancyZones'; +import type { OccupancyZone, Track } from '../types'; const OccupancyZonesLayer = ({ - useDraw, + tracks, + occupancyZones, + position, selectedTrainId, - setSelectedTrainId, - mousePosition, }: { - useDraw: (layer: LayerType, fn: DrawingFunction) => void; - selectedTrainId: string; - setSelectedTrainId: (id: string) => void; - mousePosition: { x: number; y: number }; + tracks: Track[]; + occupancyZones: OccupancyZone[]; + position: number; + selectedTrainId?: string; }) => { const drawingFunction = useCallback( - (ctx, { getTimePixel, tracks, occupancyZones, trackOccupancyWidth, trackOccupancyHeight }) => { - if (trackOccupancyHeight && trackOccupancyWidth) - drawOccupancyZones({ - ctx, - width: trackOccupancyWidth, - height: trackOccupancyHeight, - tracks, - occupancyZones, - getTimePixel, - selectedTrainId, - setSelectedTrainId, - mousePosition, - }); + (ctx, stcContext) => { + drawOccupancyZones(ctx, stcContext, { + tracks, + occupancyZones, + selectedTrainId, + position, + }); }, - [mousePosition, selectedTrainId, setSelectedTrainId] + [occupancyZones, position, selectedTrainId, tracks] ); - useDraw('paths', drawingFunction); + useDraw('overlay', drawingFunction); return null; }; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx index 81672ba943c..6bd1a361a93 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx @@ -1,41 +1,18 @@ import { useCallback } from 'react'; -import { type LayerType, type DrawingFunction } from '../../../spaceTimeChart/lib/types'; +import { type DrawingFunction, useDraw } from '../../../spaceTimeChart'; import { drawTracks } from '../helpers/drawElements/drawTracks'; +import type { Track } from '../types'; -const TracksLayer = ({ useDraw }: { useDraw: (layer: LayerType, fn: DrawingFunction) => void }) => { +const TracksLayer = ({ tracks, position }: { tracks: Track[]; position: number }) => { const drawingFunction = useCallback( - ( - ctx, - { - timeOrigin, - timePixelOffset, - getTimePixel, - tracks, - trackOccupancyWidth, - trackOccupancyHeight, - timeScale, - theme: { timeRanges, breakpoints }, - } - ) => { - if (trackOccupancyHeight && trackOccupancyWidth) - drawTracks({ - ctx, - width: trackOccupancyWidth, - height: trackOccupancyHeight, - tracks, - timeOrigin, - timePixelOffset, - getTimePixel, - timeRanges, - breakpoints, - timeScale, - }); + (ctx, stcContext) => { + drawTracks(ctx, stcContext, position, tracks); }, - [] + [position, tracks] ); - useDraw('background', drawingFunction); + useDraw('overlay', drawingFunction); return null; }; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts index bddf7d341e1..ca9678195d9 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts @@ -1,5 +1,4 @@ import { type TICKS_PATTERN } from './consts'; -import { type DrawingFunction, type LayerType } from '../../spaceTimeChart/lib/types'; export type Track = { id: string; @@ -8,28 +7,17 @@ export type Track = { }; export type OccupancyZone = { - id: string; + trainId: string; trackId: string; arrivalTrainName: string; departureTrainName: string; + arrivalDirection?: 'up' | 'down'; + departureDirection?: 'up' | 'down'; color: string; originStation?: string; destinationStation?: string; - arrivalTime: Date; - departureTime: Date; -}; - -export type TrackOccupancyCanvasProps = { - opId: string; - useDraw: (layer: LayerType, fn: DrawingFunction) => void; - setCanvasesRoot: (root: HTMLDivElement | null) => void; - selectedTrainId: string; - setSelectedTrainId: (id: string) => void; - mousePosition: { x: number; y: number }; -}; - -export type TrackOccupancyManchetteProps = { - tracks: Track[]; + arrivalTime: number; + departureTime: number; }; export type TickPattern = keyof typeof TICKS_PATTERN; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/index.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/index.ts index f09b94e7575..aace56ae32e 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/index.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/index.ts @@ -1,4 +1,7 @@ import './styles/main.css'; +export * from './components/types'; +export * from './components/consts'; export { default as TrackOccupancyCanvas } from './components/TrackOccupancyCanvas'; export { default as TrackOccupancyManchette } from './components/TrackOccupancyManchette'; +export { default as TrackOccupancyStandalone } from './components/TrackOccupancyStandalone'; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css b/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css index e3f7c6e4b95..ba23ec508d8 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css @@ -2,7 +2,7 @@ @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; -#track-occupancy-manchette, +.track-occupancy-manchette, .canvas-container { height: 100%; width: 100%; @@ -13,12 +13,9 @@ } } -#track-occupancy-manchette { - padding: 10px 0; - @apply bg-ambientB-10; - box-shadow: - inset 0 1px 0 0 rgb(255 255 255), - inset -1px 0 0 0 rgba(0, 0, 0, 0.25); +.track-occupancy-manchette { + padding-top: 10px; + @apply bg-ambientB-15; } .canvas-container { @@ -71,3 +68,39 @@ width: 100%; position: absolute; } + +.track-occupancy-standalone { + width: auto; + overflow: hidden; + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.22), + 0 4px 7px -3px rgba(255, 171, 88, 0.17), + inset 0 1px 0 0 rgb(255, 255, 255); + border-radius: 10px; + + .manchette-space-time-chart-wrapper { + overflow: auto; + flex: 1; + } + + .main-container-header { + height: 40px; + padding-left: 16px; + border-radius: 10px 10px 0 0; + box-shadow: + inset 0 1px 0 0 rgb(255, 255, 255), + inset 0 -1px 0 0 rgba(0, 0, 0, 0.25); + } + + .manchette { + height: 100%; + } + .manchette-container { + width: 200px; + overflow: hidden; + height: fit-content; + } + .manchette-actions { + display: none; + } +} From 936a85d8e8aa5a853641639955b1715551fc9bcb Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 25 Apr 2025 15:32:48 +0200 Subject: [PATCH 05/13] ui-charts: fixes some manchette stories Some code from useManchetteWithSpaceTimeChart stories was out of date, this commit fixes those occurances. Signed-off-by: Alexis Jacomy --- .../manchetteWithSpaceTimeChart/assets/sampleData.ts | 6 +++--- .../manchetteWithSpaceTimeChart/split.stories.tsx | 9 +++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/sampleData.ts b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/sampleData.ts index 689812208c9..8171291f8bb 100644 --- a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/sampleData.ts +++ b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/sampleData.ts @@ -159,7 +159,7 @@ export const SAMPLE_WAYPOINTS: Waypoint[] = [ export const SAMPLE_PATHS_DATA: ProjectPathTrainResult[] = [ { - id: 1, + id: '1', name: 'Train 1', departureTime: new Date('2024-10-23T09:00:00Z'), spaceTimeCurves: [ @@ -189,7 +189,7 @@ export const SAMPLE_PATHS_DATA: ProjectPathTrainResult[] = [ ], }, { - id: 2, + id: '2', name: 'Train 2', departureTime: new Date('2024-10-23T09:15:00Z'), spaceTimeCurves: [ @@ -219,7 +219,7 @@ export const SAMPLE_PATHS_DATA: ProjectPathTrainResult[] = [ ], }, { - id: 3, + id: '3', name: 'Train 3', departureTime: new Date('2024-10-23T09:30:00Z'), spaceTimeCurves: [ diff --git a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/split.stories.tsx b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/split.stories.tsx index a0f1d1d7220..bf8b098381d 100644 --- a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/split.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/split.stories.tsx @@ -106,7 +106,7 @@ const SplitManchetteWithSpaceTimeChartWrapper = ({ const manchetteWithSpaceTimeChartRef = useRef(null); const spaceTimeChartRef = useRef(null); - const paths = usePaths(projectPathTrainResult, selectedTrain); + const paths = usePaths(projectPathTrainResult); const { manchetteProps, spaceTimeChartProps, handleScroll } = useManchetteWithSpaceTimeChart({ waypoints, manchetteWithSpaceTimeChartRef, @@ -128,7 +128,12 @@ const SplitManchetteWithSpaceTimeChartWrapper = ({
{paths.map((path) => ( - + ))}
From 659480c4b49be858c6422500355cf45c068183a6 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 25 Apr 2025 17:32:32 +0200 Subject: [PATCH 06/13] ui-charts: normalizes millimeters in stories There were some weird issues due to the fact that data for the manchette and useManchetteWithSpaceTimeChart were in millimeters, and data for the SpaceTimeChart were in meters. This commit migrates everything SpaceTimeChart-related to millimeters. Signed-off-by: Alexis Jacomy --- .../spaceTimeChart/helpers/components.tsx | 6 +-- .../spaceTimeChart/helpers/consts.ts | 2 +- .../ui-charts/spaceTimeChart/helpers/paths.ts | 48 +++++++++++-------- .../horizontal-zoom.stories.tsx | 2 +- .../spaceTimeChart/performances.stories.tsx | 2 +- .../spaceTimeChart/quadrilateral.stories.tsx | 10 ++-- .../spaceTimeChart/rectangle-zoom.stories.tsx | 22 ++++----- .../spaceTimeChart/split.stories.tsx | 2 +- .../spaceTimeChart/work-schedules.stories.tsx | 15 +++--- 9 files changed, 60 insertions(+), 49 deletions(-) diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx index 1444d444486..1d9036ea410 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx @@ -8,7 +8,7 @@ import { } from '@osrd-project/ui-charts'; import { round } from 'lodash'; -import { WHITE_75 } from './consts'; +import { KILOMETER, WHITE_75 } from './consts'; import { formatTimeLength } from './utils'; /** @@ -90,12 +90,12 @@ const DataLabel = ({ {isDiff ? ( <>
Time difference: {formatTimeLength(new Date(data.time))}
-
Distance to mark: {round(data.position).toLocaleString()} m
+
Distance to mark: {round(data.position / KILOMETER).toLocaleString()} km
) : ( <>
Time: {new Date(data.time).toLocaleTimeString()}
-
Distance: {round(data.position).toLocaleString()} m
+
Distance: {round(data.position / KILOMETER).toLocaleString()} km
)}
diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts index 01d7b110724..52fbfc7716e 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts @@ -20,4 +20,4 @@ export const MINUTE = 60 * SECOND; export const HOUR = 60 * MINUTE; // Same for distances in meters: -export const KILOMETER = 1000; +export const KILOMETER = 1000000; diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts index 42a62a84e4b..95343af4fd1 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts @@ -1,7 +1,8 @@ import type { OperationalPoint, PathData, PathLevel } from '@osrd-project/ui-charts'; import { keyBy } from 'lodash'; -const KM = 1000; +import { KILOMETER } from './consts'; + const MIN = 60 * 1000; export function getPaths( @@ -64,37 +65,37 @@ export const OPERATIONAL_POINTS: OperationalPoint[] = [ { id: 'city-a', label: 'Point A', - position: 0 * KM, + position: 0 * KILOMETER, importanceLevel: 1, }, { id: 'city-b', label: 'Point B', - position: 10 * KM, + position: 10 * KILOMETER, importanceLevel: 2, }, { id: 'city-c', label: 'Point C', - position: 60 * KM, + position: 60 * KILOMETER, importanceLevel: 1, }, { id: 'city-d', label: 'Point D', - position: 70 * KM, + position: 70 * KILOMETER, importanceLevel: 2, }, { id: 'city-e', label: 'Point E', - position: 90 * KM, + position: 90 * KILOMETER, importanceLevel: 2, }, { id: 'city-f', label: 'Point F', - position: 140 * KM, + position: 140 * KILOMETER, importanceLevel: 1, }, ]; @@ -139,7 +140,7 @@ export const PATHS: PathDisplay[] = [ OPERATIONAL_POINTS, 3 * MIN, 60 * MIN, - (80 * KM) / (60 * MIN), + (80 * KILOMETER) / (60 * MIN), 2, +START_DATE + 10 * MIN, { @@ -156,7 +157,7 @@ export const PATHS: PathDisplay[] = [ OPERATIONAL_POINTS, 3 * MIN, 60 * MIN, - (80 * KM) / (60 * MIN), + (80 * KILOMETER) / (60 * MIN), 1, +START_DATE + 40 * MIN, { @@ -175,7 +176,7 @@ export const PATHS: PathDisplay[] = [ OPERATIONAL_POINTS, 3 * MIN, 30 * MIN, - (80 * KM) / (60 * MIN), + (80 * KILOMETER) / (60 * MIN), 5, +START_DATE, { color: '#FF362E' } @@ -185,24 +186,33 @@ export const PATHS: PathDisplay[] = [ REVERSED_POINTS, 3 * MIN, 35 * MIN, - (80 * KM) / (60 * MIN), + (80 * KILOMETER) / (60 * MIN), 4, +START_DATE, { color: '#FF8E3D' } ), // Fast trains: - ...getPaths('fast', EXTREME_POINTS, 5 * MIN, 50 * MIN, (140 * KM) / (60 * MIN), 3, +START_DATE, { - color: '#526CE8', - fromEnd: 'out', - toEnd: 'out', - }), + ...getPaths( + 'fast', + EXTREME_POINTS, + 5 * MIN, + 50 * MIN, + (140 * KILOMETER) / (60 * MIN), + 3, + +START_DATE, + { + color: '#526CE8', + fromEnd: 'out', + toEnd: 'out', + } + ), ...getPaths( 'fast-reversed', REVERSED_EXTREME_POINTS, 5 * MIN, 45 * MIN, - (140 * KM) / (60 * MIN), + (140 * KILOMETER) / (60 * MIN), 3, +START_DATE, { color: '#66C0F1', fromEnd: 'out', toEnd: 'out' } @@ -214,7 +224,7 @@ export const PATHS: PathDisplay[] = [ BACK_AND_FORTH_POINTS, 10 * MIN, 30 * MIN, - (80 * KM) / (60 * MIN), + (80 * KILOMETER) / (60 * MIN), 2, +START_DATE + 15 * MIN, { color: '#286109', toEnd: 'out' } @@ -224,7 +234,7 @@ export const PATHS: PathDisplay[] = [ REVERSED_BACK_AND_FORTH_POINTS, 12 * MIN, 30 * MIN, - (80 * KM) / (60 * MIN), + (80 * KILOMETER) / (60 * MIN), 2, +START_DATE + 3 * MIN, { color: '#64cc2b', toEnd: 'out' } diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx index ee686c90ad4..d3d55bb848d 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx @@ -73,7 +73,7 @@ const SpaceTimeHorizontalZoomWrapper = ({ { from: 0, to: 75000, - coefficient: 300, + coefficient: 300000, }, ]; return ( diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/performances.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/performances.stories.tsx index 61ce33d8a6e..220619ce2d3 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/performances.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/performances.stories.tsx @@ -53,7 +53,7 @@ const Wrapper = ({ return range(operationalPointsCount).map((i) => ({ id: `op-${i}`, label: `Operational point n°${i + 1}`, - position: (position += random(50000, 150000)), + position: (position += random(50 * KILOMETER, 150 * KILOMETER)), importanceLevel: !i || i === operationalPointsCount - 1 || Math.random() > 0.8 ? 1 : 2, })); }, [operationalPointsCount]); diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/quadrilateral.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/quadrilateral.stories.tsx index 28d2f0b9995..28f645fb3c6 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/quadrilateral.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/quadrilateral.stories.tsx @@ -8,7 +8,7 @@ import { } from '@osrd-project/ui-charts'; import type { Meta } from '@storybook/react'; -import { HOUR } from './helpers/consts'; +import { HOUR, KILOMETER } from './helpers/consts'; import { OPERATIONAL_POINTS, PATHS, START_DATE } from './helpers/paths'; import { X_ZOOM_LEVEL, Y_ZOOM_LEVEL } from './helpers/utils'; @@ -26,10 +26,10 @@ type WrapperProps = { const QuadrilateralMock: QuadrilateralProps = { vertices: [ - { time: START_DATE.getTime() + HOUR, position: 3000 }, - { time: START_DATE.getTime() + HOUR * 3, position: 3000 }, - { time: START_DATE.getTime() + HOUR * 2, position: 11000 }, - { time: START_DATE.getTime(), position: 11000 }, + { time: START_DATE.getTime() + HOUR, position: 3 * KILOMETER }, + { time: START_DATE.getTime() + HOUR * 3, position: 3 * KILOMETER }, + { time: START_DATE.getTime() + HOUR * 2, position: 11 * KILOMETER }, + { time: START_DATE.getTime(), position: 11 * KILOMETER }, ], style: { backgroundColor: 'lightblue', diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/rectangle-zoom.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/rectangle-zoom.stories.tsx index 8e9dc4ccdcc..f438cdc7c12 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/rectangle-zoom.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/rectangle-zoom.stories.tsx @@ -21,6 +21,7 @@ import './styles/rectangle-zoom.css'; import { MouseTracker } from './helpers/components'; import { OPERATIONAL_POINTS, PATHS } from './helpers/paths'; import { getDiff } from './helpers/utils'; +import { KILOMETER } from './helpers/consts'; const DEFAULT_WIDTH = 1000; const DEFAULT_HEIGHT = 500; @@ -30,9 +31,9 @@ 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_METER_PER_PX = 10000; -const MAX_ZOOM_METER_PER_PX = 10; -const DEFAULT_ZOOM_METER_PER_PX = 300; +const MIN_SPACE_ZOOM = 10 * KILOMETER; +const MAX_SPACE_ZOOM = 0.01 * KILOMETER; +const DEFAULT_SPACE_ZOOM = 0.3 * KILOMETER; type SpaceTimeHorizontalZoomWrapperProps = { swapAxes: boolean; spaceOrigin: number; @@ -50,11 +51,10 @@ const timeScaleToZoomValue = (timeScale: number) => Math.log(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX); const zoomValueToSpaceScale = (slider: number) => - MIN_ZOOM_METER_PER_PX * Math.pow(MAX_ZOOM_METER_PER_PX / MIN_ZOOM_METER_PER_PX, slider / 100); + MIN_SPACE_ZOOM * Math.pow(MAX_SPACE_ZOOM / MIN_SPACE_ZOOM, slider / 100); const spaceScaleToZoomValue = (spaceScale: number) => - (100 * Math.log(spaceScale / MIN_ZOOM_METER_PER_PX)) / - Math.log(MAX_ZOOM_METER_PER_PX / MIN_ZOOM_METER_PER_PX); + (100 * Math.log(spaceScale / MIN_SPACE_ZOOM)) / Math.log(MAX_SPACE_ZOOM / MIN_SPACE_ZOOM); type StoryState = { timeZoomValue: number; @@ -84,7 +84,7 @@ const RectangleZoomWrapper = ({ }: SpaceTimeHorizontalZoomWrapperProps) => { const [state, setState] = useState({ timeZoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX), - spaceZoomValue: spaceScaleToZoomValue(DEFAULT_ZOOM_METER_PER_PX), + spaceZoomValue: spaceScaleToZoomValue(DEFAULT_SPACE_ZOOM), xOffset, yOffset, panning: null, @@ -96,7 +96,7 @@ const RectangleZoomWrapper = ({ const timeScale = zoomValueToTimeScale(state.timeZoomValue); const spaceScale: SpaceScale[] = [ { - to: 100000, + to: 100 * KILOMETER, coefficient: zoomValueToSpaceScale(state.spaceZoomValue), // meter/px }, ]; @@ -122,7 +122,7 @@ const RectangleZoomWrapper = ({ } const newTimeScale = clamp(chosenTimeScale, MAX_ZOOM_MS_PER_PX, MIN_ZOOM_MS_PER_PX); - const newSpaceScale = clamp(chosenSpaceScale, MAX_ZOOM_METER_PER_PX, MIN_ZOOM_METER_PER_PX); + const newSpaceScale = clamp(chosenSpaceScale, MAX_SPACE_ZOOM, MIN_SPACE_ZOOM); const timeZoomValue = timeScaleToZoomValue(newTimeScale); const spaceZoomValue = spaceScaleToZoomValue(newSpaceScale); @@ -227,14 +227,14 @@ const RectangleZoomWrapper = ({ ...prev, ...(!swapAxes ? { timeZoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX) } - : { spaceZoomValue: spaceScaleToZoomValue(DEFAULT_ZOOM_METER_PER_PX) }), + : { spaceZoomValue: spaceScaleToZoomValue(DEFAULT_SPACE_ZOOM) }), xOffset: 0, })); } else { setState((prev) => ({ ...prev, ...(!swapAxes - ? { spaceZoomValue: spaceScaleToZoomValue(DEFAULT_ZOOM_METER_PER_PX) } + ? { spaceZoomValue: spaceScaleToZoomValue(DEFAULT_SPACE_ZOOM) } : { timeZoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX) }), yOffset: 0, })); diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/split.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/split.stories.tsx index 0773c699ab9..7e985c1cefa 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/split.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/split.stories.tsx @@ -17,7 +17,7 @@ import { AMBIANT_A10 } from './helpers/consts'; import { OPERATIONAL_POINTS, PATHS } from './helpers/paths'; import { X_ZOOM_LEVEL, Y_ZOOM_LEVEL, zoom, getDiff } from './helpers/utils'; -const COEFFICIENT = 300; +const COEFFICIENT = 300000; /** * This component renders a colored area where the line only has one track: diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/work-schedules.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/work-schedules.stories.tsx index 073f93e6f7c..4b45dc8146d 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/work-schedules.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/work-schedules.stories.tsx @@ -12,6 +12,7 @@ import { import type { Meta } from '@storybook/react'; import upward from './assets/images/ScheduledMaintenanceUp.svg'; +import { KILOMETER } from './helpers/consts'; import { OPERATIONAL_POINTS, PATHS } from './helpers/paths'; import { getDiff } from './helpers/utils'; @@ -24,8 +25,8 @@ const SAMPLE_WORK_SCHEDULES: WorkSchedule[] = [ timeStart: new Date('2024-04-02T00:00:00Z'), timeEnd: new Date('2024-04-02T00:15:00Z'), spaceRanges: [ - [20000, 35000], - [45000, 60000], + [20 * KILOMETER, 35 * KILOMETER], + [45 * KILOMETER, 60 * KILOMETER], ], }, { @@ -33,15 +34,15 @@ const SAMPLE_WORK_SCHEDULES: WorkSchedule[] = [ timeStart: new Date('2024-04-02T00:15:00Z'), timeEnd: new Date('2024-04-02T01:00:00Z'), spaceRanges: [ - [80000, 100000], - [110000, 140000], + [80 * KILOMETER, 100 * KILOMETER], + [110 * KILOMETER, 140 * KILOMETER], ], }, { type: 'TRACK', timeStart: new Date('2024-04-02T01:30:00Z'), timeEnd: new Date('2024-04-02T02:30:00Z'), - spaceRanges: [[50000, 100000]], + spaceRanges: [[50 * KILOMETER, 100 * KILOMETER]], }, ]; @@ -75,8 +76,8 @@ const WorkSchedulesWrapper = ({ const spaceScale = [ { from: 0, - to: 75000, - coefficient: 300, + to: 75 * KILOMETER, + coefficient: 300000, }, ]; return ( From 4e200e47e0e1b2d0a3fc9f17f6c52dfbc48c826a Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 25 Apr 2025 17:35:44 +0200 Subject: [PATCH 07/13] ui-charts: drafts TOD within STC story This commit addresses ticket: https://github.com/osrd-project/osrd-confidential/issues/945 Details: - Adds possibility to add some top padding to TrackOccupancyCanvas - Adds possibility to display a close button in the top right of the TrackOccupancyCanvas - Allows giving children to TrackOccupancyManchette - Adds Waypoint (as WaypointComponent) to exports from ui-charts - Creates new trac-occupancy story, that shows how to display TrackOccupancy diagrams within split sections of the Manchette and the SpaceTimeChart Signed-off-by: Alexis Jacomy --- .../assets/trackOccupancyData.ts | 8 + .../track-occupancy.stories.tsx | 154 ++++++++++++++++++ .../ui-charts/spaceTimeChart/helpers/paths.ts | 75 ++++++++- front/ui/ui-charts/src/manchette/index.ts | 1 + .../components/TrackOccupancyCanvas.tsx | 31 +++- .../components/TrackOccupancyManchette.tsx | 5 +- .../drawElements/drawOccupancyZones.ts | 4 +- .../helpers/drawElements/drawTracks.ts | 13 +- .../components/layers/OccupancyZonesLayer.tsx | 5 +- .../components/layers/TracksLayer.tsx | 14 +- .../src/trackOccupancyDiagram/styles/main.css | 19 ++- 11 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/trackOccupancyData.ts create mode 100644 front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx diff --git a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/trackOccupancyData.ts b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/trackOccupancyData.ts new file mode 100644 index 00000000000..16f5fc54eb5 --- /dev/null +++ b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/assets/trackOccupancyData.ts @@ -0,0 +1,8 @@ +export const TRACKS = [ + { id: '1', name: 'EV', line: '123456' }, + { id: '2', name: '2', line: '456123' }, + { id: '3', name: '2bis', line: '135246' }, + { id: '4', name: 'Z', line: '654321' }, + { id: '5', name: '1bis', line: '615243' }, + { id: '6', name: '1', line: '523416' }, +]; diff --git a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx new file mode 100644 index 00000000000..698ec511c3d --- /dev/null +++ b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx @@ -0,0 +1,154 @@ +import React, { useMemo, useRef, useState } from 'react'; + +import { + Manchette, + PathLayer, + SpaceTimeChart, + TrackOccupancyCanvas, + TrackOccupancyManchette, + useManchetteWithSpaceTimeChart, + WaypointComponent, + TRACK_HEIGHT_CONTAINER, + type OccupancyZone, + type Track, + isInteractiveWaypoint, +} from '@osrd-project/ui-charts'; +import '@osrd-project/ui-charts/dist/theme.css'; +import '@osrd-project/ui-core/dist/theme.css'; +import type { Meta } from '@storybook/react'; + +import { + getOccupancyZonesFromPath, + OPERATIONAL_POINTS, + PATHS, +} from '../spaceTimeChart/helpers/paths'; + +const BASE_WAYPOINT_HEIGHT = 32; + +/** + * This story shows how to render a Manchette with a SpaceTimeChart, and showing a + * TrackOccupancyDiagram layer when selecting an operational point. + */ + +/** + * This component shows how to use the useManchetteWithSpaceTimeChart hook with track-occupancy + * diagrams: + */ +const TrackOccupancyDiagramWithinSpaceTimeChartWrapper = ({ height = 561 }: { height: number }) => { + // TODO: Restore trains selection from GOV + const [selectedTrain, _setSelectedTrain] = useState(undefined); + const [selectedWaypoint, setSelectedWaypoint] = useState( + OPERATIONAL_POINTS[2].id + ); + const manchetteWithSpaceTimeChartRef = useRef(null); + const spaceTimeChartRef = useRef(null); + const operationalPoints = OPERATIONAL_POINTS; + const paths = PATHS; + + const splitPoints = useMemo(() => { + const operationalPoint = operationalPoints.find((wp) => wp.id === selectedWaypoint); + if (!operationalPoint) return []; + + // Fake tracks: + const tracks: Track[] = [ + { id: '1', name: 'EV', line: 'line' }, + { id: '2', name: '2', line: 'line' }, + { id: '3', name: '2bis', line: 'line' }, + ]; + const occupancyZones: OccupancyZone[] = paths.flatMap((path, i) => + getOccupancyZonesFromPath(path.points, operationalPoint.position, { + trainId: path.id, + trackId: tracks[i % tracks.length].id, // (i.e. pick some random track) + arrivalTrainName: 'foo', + departureTrainName: 'bar', + color: path.color, + }) + ); + + return [ + { + id: operationalPoint.id, + position: operationalPoint.position, + size: tracks.length * TRACK_HEIGHT_CONTAINER + BASE_WAYPOINT_HEIGHT, + spaceTimeChartNode: ( + setSelectedWaypoint(undefined)} + topPadding={BASE_WAYPOINT_HEIGHT} + /> + ), + manchetteNode: ( + +
+ setSelectedWaypoint(undefined), + }} + isActive={false} + isMenuActive={false} + /> +
+
+ ), + }, + ]; + }, [paths, selectedTrain, selectedWaypoint, operationalPoints]); + + const { manchetteProps, spaceTimeChartProps, handleScroll } = useManchetteWithSpaceTimeChart({ + waypoints: operationalPoints.map((op) => ({ + id: op.id, + position: op.position, + name: op.label, + weight: op.importanceLevel, + })), + manchetteWithSpaceTimeChartRef, + height, + spaceTimeChartRef, + splitPoints, + defaultTimeOrigin: Math.min(...paths.map((p) => +p.points[0].time)), + }); + + return ( +
+
+ + isInteractiveWaypoint(content) + ? { ...content, onClick: (waypointId) => setSelectedWaypoint(waypointId) } + : content + )} + /> +
+ + {paths.map((path) => ( + + ))} + +
+
+
+ ); +}; + +const meta: Meta = { + title: 'Manchette with SpaceTimeChart/Track-occupancy display', + component: TrackOccupancyDiagramWithinSpaceTimeChartWrapper, +}; + +export default meta; + +export const Default = { + args: {}, +}; diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts index 95343af4fd1..d251ed724b4 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts @@ -1,5 +1,11 @@ -import type { OperationalPoint, PathData, PathLevel } from '@osrd-project/ui-charts'; -import { keyBy } from 'lodash'; +import { + DataPoint, + OccupancyZone, + OperationalPoint, + PathData, + PathLevel, +} from '@osrd-project/ui-charts'; +import { cloneDeep, inRange, keyBy } from 'lodash'; import { KILOMETER } from './consts'; @@ -240,3 +246,68 @@ export const PATHS: PathDisplay[] = [ { color: '#64cc2b', toEnd: 'out' } ), ]; + +export function getOccupancyZonesFromPath( + points: DataPoint[], + waypointPosition: number, + additionalAttributes: T +) { + const res: (Pick< + OccupancyZone, + 'arrivalDirection' | 'departureDirection' | 'arrivalTime' | 'departureTime' + > & + T)[] = []; + + points.forEach(({ position, time }, i, a) => { + if (!i) return; + const { position: prevPosition, time: prevTime } = a[i - 1]; + const next = a[i + 1]; + const beforePrev = a[i - 2]; + + // First case: Segment on waypoint + if (position === waypointPosition && prevPosition === waypointPosition) { + res.push({ + arrivalDirection: !beforePrev + ? undefined + : beforePrev.position < prevPosition + ? 'up' + : 'down', + arrivalTime: prevTime, + departureDirection: !next ? undefined : next.position < position ? 'up' : 'down', + departureTime: time, + ...cloneDeep(additionalAttributes), + }); + } + + // Second case: Single point exactly on waypoint + else if (position === waypointPosition && (!next || next.position !== waypointPosition)) { + res.push({ + arrivalDirection: prevPosition < waypointPosition ? 'up' : 'down', + arrivalTime: time, + departureDirection: !next ? undefined : next.position < position ? 'up' : 'down', + departureTime: time, + ...cloneDeep(additionalAttributes), + }); + } + + // Third case: Segment crossing waypoint + else if ( + position !== waypointPosition && + prevPosition !== waypointPosition && + inRange(waypointPosition, prevPosition, position) + ) { + const crossTime = + prevTime + + ((waypointPosition - prevPosition) / (position - prevPosition)) * (time - prevTime); + res.push({ + arrivalDirection: prevPosition < waypointPosition ? 'up' : 'down', + arrivalTime: crossTime, + departureDirection: position < waypointPosition ? 'up' : 'down', + departureTime: crossTime, + ...cloneDeep(additionalAttributes), + }); + } + }); + + return res; +} diff --git a/front/ui/ui-charts/src/manchette/index.ts b/front/ui/ui-charts/src/manchette/index.ts index 50dac4150e4..d7c2f714cc6 100644 --- a/front/ui/ui-charts/src/manchette/index.ts +++ b/front/ui/ui-charts/src/manchette/index.ts @@ -2,6 +2,7 @@ import '@osrd-project/ui-core/dist/theme.css'; import './styles/main.css'; import './consts'; +export { default as WaypointComponent } from './components/Waypoint'; export { default as Manchette, type ManchetteProps } from './components/Manchette'; export { default as ManchetteWithSpaceTimeChart, diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx index a64ecd78347..9bfc00deff3 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx @@ -1,28 +1,55 @@ -import React from 'react'; +import React, { useContext } from 'react'; + +import { X } from '@osrd-project/ui-icons'; import OccupancyZonesLayer from './layers/OccupancyZonesLayer'; import TracksLayer from './layers/TracksLayer'; import type { OccupancyZone, Track } from './types'; +import { SpaceTimeChartContext } from '../../spaceTimeChart'; + +const CloseButton = ({ position, onClose }: { position: number; onClose: () => void }) => { + const { getSpacePixel } = useContext(SpaceTimeChartContext); + + return ( + + ); +}; const TrackOccupancyCanvas = ({ position, tracks, occupancyZones, selectedTrainId, + onClose, + topPadding = 0, }: { position: number; tracks: Track[]; occupancyZones: OccupancyZone[]; selectedTrainId?: string; + onClose?: () => void; + topPadding?: number; }) => ( <> - + + {onClose && } ); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx index 44d6ba7eb29..61f25f7c52b 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyManchette.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { type PropsWithChildren } from 'react'; import { TRACK_HEIGHT_CONTAINER } from './consts'; import type { Track } from './types'; -const TrackOccupancyManchette = ({ tracks }: { tracks: Track[] }) => ( +const TrackOccupancyManchette = ({ tracks, children }: PropsWithChildren<{ tracks: Track[] }>) => (
+ {children} {tracks.map((track) => ( // height is shared between manchette and canvas components
diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts index d4ed250a228..cd0b3b646d7 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts @@ -188,17 +188,19 @@ export const drawOccupancyZones = ( tracks, position, selectedTrainId, + topPadding = 0, }: { occupancyZones: OccupancyZone[]; tracks: Track[]; position: number; selectedTrainId?: string; + topPadding?: number; } ) => { if (!tracks || !occupancyZones || occupancyZones.length === 0) return; const { getTimePixel, getSpacePixel } = stcContext; - const baseY = getSpacePixel(position); + const baseY = getSpacePixel(position) + topPadding; const sortedOccupancyZones = occupancyZones.sort((a, b) => a.arrivalTime - b.arrivalTime); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts index a2444159bbc..2841be6bb71 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts @@ -10,8 +10,15 @@ const { HOUR_BACKGROUND_1, HOUR_BACKGROUND_2 } = COLORS; export const drawTracks = ( ctx: CanvasRenderingContext2D, stcContext: SpaceTimeChartContextType, - position: number, - tracks: Track[] + { + position, + tracks, + topPadding = 0, + }: { + position: number; + tracks: Track[]; + topPadding: number; + } ) => { const { width, @@ -42,7 +49,7 @@ export const drawTracks = ( } ctx.save(); - ctx.translate(0, yStart); + ctx.translate(0, yStart + topPadding); tracks?.forEach((_, index) => { const trackTranslate = index === 0 ? CANVAS_PADDING : TRACK_HEIGHT_CONTAINER; ctx.translate(0, trackTranslate); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx index 25dc325ef4e..eb280e01926 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx @@ -8,11 +8,13 @@ const OccupancyZonesLayer = ({ tracks, occupancyZones, position, + topPadding, selectedTrainId, }: { tracks: Track[]; occupancyZones: OccupancyZone[]; position: number; + topPadding: number; selectedTrainId?: string; }) => { const drawingFunction = useCallback( @@ -22,9 +24,10 @@ const OccupancyZonesLayer = ({ occupancyZones, selectedTrainId, position, + topPadding, }); }, - [occupancyZones, position, selectedTrainId, tracks] + [occupancyZones, position, selectedTrainId, topPadding, tracks] ); useDraw('overlay', drawingFunction); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx index 6bd1a361a93..ea12192df9e 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx @@ -4,12 +4,20 @@ import { type DrawingFunction, useDraw } from '../../../spaceTimeChart'; import { drawTracks } from '../helpers/drawElements/drawTracks'; import type { Track } from '../types'; -const TracksLayer = ({ tracks, position }: { tracks: Track[]; position: number }) => { +const TracksLayer = ({ + tracks, + position, + topPadding, +}: { + tracks: Track[]; + position: number; + topPadding: number; +}) => { const drawingFunction = useCallback( (ctx, stcContext) => { - drawTracks(ctx, stcContext, position, tracks); + drawTracks(ctx, stcContext, { position, topPadding, tracks }); }, - [position, tracks] + [position, topPadding, tracks] ); useDraw('overlay', drawingFunction); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css b/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css index ba23ec508d8..bb8115925a3 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css @@ -2,25 +2,28 @@ @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; -.track-occupancy-manchette, -.canvas-container { +.track-occupancy-manchette { height: 100%; width: 100%; border-radius: inherit; + padding-top: 10px; > canvas { border-radius: inherit; } -} -.track-occupancy-manchette { - padding-top: 10px; @apply bg-ambientB-15; + + .waypoint::after, + .waypoint-separator::after { + height: 0; + } } -.canvas-container { - @apply bg-white-100; - position: relative; +.close-track-occupancy-panel { + position: absolute; + right: 0; + margin: 0.875rem; } .track { From 9cf660bb7074e0f38901833c3372878f1e7f9fc3 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Tue, 29 Apr 2025 15:29:35 +0200 Subject: [PATCH 08/13] ui-charts: restores clicking trains in the TOD Details: - Implements back clicks detection in the TrackOccupancy diagram, using the picking framework from the SpaceTimeDiagram - Adds an example showing how it works in the trackOccupancyDiagram/rendering story Signed-off-by: Alexis Jacomy --- .../rendering.stories.tsx | 1 + .../ui-charts/src/spaceTimeChart/lib/types.ts | 2 +- .../components/TrackOccupancyStandalone.tsx | 23 +- .../drawElements/drawOccupancyZones.ts | 197 ++++------------- .../drawElements/drawOccupancyZonesTexts.ts | 8 +- .../components/layers/OccupancyZonesLayer.tsx | 208 +++++++++++++++++- .../trackOccupancyDiagram/components/types.ts | 2 + 7 files changed, 265 insertions(+), 176 deletions(-) diff --git a/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx index f5348a283ca..1a0b6f5d13a 100755 --- a/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx @@ -30,6 +30,7 @@ const TrackOccupancyDiagramStory = ({ tracks={TRACKS} occupancyZones={OCCUPANCY_ZONES} selectedTrainId={selectedTrainId} + onSelectedTrainIdChange={setSelectedTrainId} height={autoHeight ? undefined : 500} />
diff --git a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts index ce008a1e80f..51017edda6d 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/lib/types.ts @@ -93,7 +93,7 @@ export type PointToData = (point: Point) => DataPoint; export type DataToPoint = (data: DataPoint) => Point; // CANVAS SPECIFIC TYPES: -export const PICKING_LAYERS = ['paths'] as const; +export const PICKING_LAYERS = ['paths', 'overlay'] as const; export type PickingLayerType = (typeof PICKING_LAYERS)[number]; export const LAYERS = ['background', 'graduations', 'paths', 'overlay', 'captions'] as const; export type LayerType = (typeof LAYERS)[number]; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx index 790961802e4..44042b03bd7 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx @@ -5,7 +5,7 @@ import { KebabHorizontal } from '@osrd-project/ui-icons'; import { TRACK_HEIGHT_CONTAINER } from './consts'; import TrackOccupancyCanvas from './TrackOccupancyCanvas'; import TrackOccupancyManchette from './TrackOccupancyManchette'; -import type { OccupancyZone, Track } from './types'; +import type { OccupancyZone, OccupancyZonePickingElement, Track } from './types'; import { Manchette, useManchetteWithSpaceTimeChart } from '../../manchette'; import { SpaceTimeChart } from '../../spaceTimeChart'; import { HOUR } from '../../spaceTimeChart/lib/consts'; @@ -14,11 +14,13 @@ const TrackOccupancyStandalone = ({ tracks, occupancyZones, selectedTrainId, + onSelectedTrainIdChange, height = TRACK_HEIGHT_CONTAINER * tracks.length, }: { tracks: Track[]; occupancyZones: OccupancyZone[]; selectedTrainId?: string; + onSelectedTrainIdChange?: (selectedTrainId?: string) => void; height?: number; }) => { const manchetteWithSpaceTimeChartRef = useRef(null); @@ -94,7 +96,24 @@ const TrackOccupancyStandalone = ({ >
- + { + if ( + hoveredItem?.layer === 'overlay' && + hoveredItem.element.type === 'occupancyZone' + ) { + const newId = (hoveredItem.element as OccupancyZonePickingElement).trainId; + onSelectedTrainIdChange(newId === selectedTrainId ? undefined : newId); + } else { + onSelectedTrainIdChange(undefined); + } + }) + } + />
diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts index cd0b3b646d7..55618a77214 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts @@ -1,22 +1,13 @@ import { drawOccupancyZonesTexts } from './drawOccupancyZonesTexts'; -import type { SpaceTimeChartContextType } from '../../../../spaceTimeChart'; -import { - TRACK_HEIGHT_CONTAINER, - CANVAS_PADDING, - OCCUPANCY_ZONE_Y_START, - OCCUPANCY_ZONE_HEIGHT, - FONTS, - COLORS, -} from '../../consts'; -import type { OccupancyZone, Track } from '../../types'; +import { getCrispLineCoordinate, type SpaceTimeChartContextType } from '../../../../spaceTimeChart'; +import { OCCUPANCY_ZONE_Y_START, OCCUPANCY_ZONE_HEIGHT, FONTS, COLORS } from '../../consts'; +import type { OccupancyZone } from '../../types'; const { SANS } = FONTS; const { REMAINING_TRAINS_BACKGROUND, WHITE_100, SELECTION_20 } = COLORS; const REMAINING_TRAINS_WIDTH = 70; const REMAINING_TRAINS_HEIGHT = 24; const REMAINING_TEXT_OFFSET = 12; -const Y_OFFSET_INCREMENT = 4; -const MAX_ZONES = 9; const X_BACKGROUND_PADDING = 4; const X_TROUGHTRAIN_BACKGROUND_PADDING = 8; const BACKGROUND_HEIGHT = 40; @@ -61,48 +52,50 @@ const drawThroughTrain = (ctx: CanvasRenderingContext2D, x: number, y: number) = ctx.stroke(); }; -const drawRemainingTrainsBox = ({ - ctx, - remainingTrainsNb, - xPosition, - yPosition, -}: { - ctx: CanvasRenderingContext2D; - remainingTrainsNb: number; - xPosition: number; - yPosition: number; -}) => { - const textY = yPosition + OCCUPANCY_ZONE_Y_START - REMAINING_TEXT_OFFSET; +export const drawRemainingTrainsBox = ( + ctx: CanvasRenderingContext2D, + { getTimePixel, getSpacePixel }: SpaceTimeChartContextType, + { + time, + position, + yOffset, + remainingTrainsNb, + }: { + time: number; + position: number; + yOffset: number; + remainingTrainsNb: number; + } +) => { + const x = getTimePixel(time); + const y = getSpacePixel(position) + yOffset; + const textY = y + OCCUPANCY_ZONE_Y_START - REMAINING_TEXT_OFFSET; ctx.fillStyle = REMAINING_TRAINS_BACKGROUND; ctx.beginPath(); - ctx.rect(xPosition, textY, REMAINING_TRAINS_WIDTH, REMAINING_TRAINS_HEIGHT); + ctx.rect(x - REMAINING_TRAINS_WIDTH / 2, textY, REMAINING_TRAINS_WIDTH, REMAINING_TRAINS_HEIGHT); ctx.fill(); ctx.stroke(); ctx.fillStyle = WHITE_100; ctx.font = SANS; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText( - `+${remainingTrainsNb} trains`, - xPosition + REMAINING_TRAINS_WIDTH / 2, - textY + REMAINING_TRAINS_HEIGHT / 2 - ); + ctx.fillText(`+${remainingTrainsNb} trains`, x, textY + REMAINING_TRAINS_HEIGHT / 2); }; -const drawOccupationZone = ( +export const drawOccupationZone = ( ctx: CanvasRenderingContext2D, stcContext: SpaceTimeChartContextType, { zone, + yOffset, position, - yZone, - selectedTrainId, + isSelected, }: { zone: OccupancyZone; + yOffset: number; position: number; - yZone: number; - selectedTrainId?: string; + isSelected?: boolean; } ) => { const isThroughTrain = zone.arrivalTime === zone.departureTime; @@ -114,12 +107,13 @@ const drawOccupationZone = ( ctx.font = '400 10px IBM Plex Mono'; const { getTimePixel, getSpacePixel } = stcContext; - const yStart = getSpacePixel(position); + const yStart = getCrispLineCoordinate(getSpacePixel(position), BACKGROUND_HEIGHT); + const y = yStart + yOffset; const yEnd = getSpacePixel(position, true); const arrivalTimePixel = getTimePixel(zone.arrivalTime); const departureTimePixel = getTimePixel(zone.departureTime); - if (selectedTrainId === zone.trainId) { + if (isSelected) { const extraWidth = isThroughTrain ? X_TROUGHTRAIN_BACKGROUND_PADDING : X_BACKGROUND_PADDING; const originTextLength = ctx.measureText(zone.originStation || '--').width; const destinationTextLength = ctx.measureText(zone.destinationStation || '--').width; @@ -128,7 +122,7 @@ const drawOccupationZone = ( ctx.beginPath(); ctx.roundRect( arrivalTimePixel - originTextLength - extraWidth, - yZone - BACKGROUND_HEIGHT / 2, + y - BACKGROUND_HEIGHT / 2, departureTimePixel - arrivalTimePixel + originTextLength + @@ -140,12 +134,13 @@ const drawOccupationZone = ( ctx.fill(); } + ctx.fillStyle = zone.color; if (isThroughTrain) { - drawThroughTrain(ctx, arrivalTimePixel, yZone); + drawThroughTrain(ctx, arrivalTimePixel, y); } else { drawDefaultZone(ctx, { x: arrivalTimePixel, - y: yZone, + y, width: departureTimePixel - arrivalTimePixel, }); } @@ -156,13 +151,13 @@ const drawOccupationZone = ( ctx.setLineDash([1, 4]); if (zone.arrivalDirection) { ctx.beginPath(); - ctx.moveTo(arrivalTimePixel, yZone); + ctx.moveTo(arrivalTimePixel, y); ctx.lineTo(arrivalTimePixel, zone.arrivalDirection === 'up' ? yStart : yEnd); ctx.stroke(); } if (zone.departureDirection) { ctx.beginPath(); - ctx.moveTo(departureTimePixel, yZone); + ctx.moveTo(departureTimePixel, y); ctx.lineTo(departureTimePixel, zone.departureDirection === 'up' ? yStart : yEnd); ctx.stroke(); } @@ -175,121 +170,7 @@ const drawOccupationZone = ( arrivalTimePixel, departureTimePixel, isThroughTrain, - selectedTrainId, - yPosition: yZone, - }); -}; - -export const drawOccupancyZones = ( - ctx: CanvasRenderingContext2D, - stcContext: SpaceTimeChartContextType, - { - occupancyZones, - tracks, - position, - selectedTrainId, - topPadding = 0, - }: { - occupancyZones: OccupancyZone[]; - tracks: Track[]; - position: number; - selectedTrainId?: string; - topPadding?: number; - } -) => { - if (!tracks || !occupancyZones || occupancyZones.length === 0) return; - - const { getTimePixel, getSpacePixel } = stcContext; - const baseY = getSpacePixel(position) + topPadding; - - const sortedOccupancyZones = occupancyZones.sort((a, b) => a.arrivalTime - b.arrivalTime); - - tracks.forEach((track, index) => { - const trackY = baseY + CANVAS_PADDING + index * TRACK_HEIGHT_CONTAINER; - - const filteredOccupancyZones = sortedOccupancyZones.filter((zone) => zone.trackId === track.id); - - let primaryArrivalTime = 0; - let primaryDepartureTime = 0; - let lastDepartureTime = primaryDepartureTime; - let yPosition = OCCUPANCY_ZONE_Y_START; - let yOffset = Y_OFFSET_INCREMENT; - let zoneCounter = 0; - let zoneIndex = 0; - - while (zoneIndex < filteredOccupancyZones.length) { - const zone = filteredOccupancyZones[zoneIndex]; - const { arrivalTime, departureTime } = zone; - - // * if the zone is not overlapping with any previous one, draw it in the center of the track - // * and reset the primary values - // * - // * if the zone is overlapping with the previous one, draw it below or above the previous one - // * depending on the overlapping counter - // * - // * if the zone is overlapping with the previous one and the counter is higher than the max zones - // * draw the remaining trains box - // * - if (arrivalTime > lastDepartureTime) { - // reset to initial value if the zone is not overlapping - yPosition = OCCUPANCY_ZONE_Y_START; - primaryArrivalTime = arrivalTime; - primaryDepartureTime = departureTime; - lastDepartureTime = departureTime; - yOffset = Y_OFFSET_INCREMENT; - zoneCounter = 1; - - drawOccupationZone(ctx, stcContext, { - zone, - position, - selectedTrainId, - yZone: trackY + yPosition, - }); - - zoneIndex++; - - continue; - } - - if (zoneCounter < MAX_ZONES) { - // if so and it's an even index, move it to the bottom, if it's an odd index, move it to the top - if (arrivalTime >= primaryArrivalTime) { - if (zoneCounter % 2 === 0) { - yPosition -= yOffset; - } else { - yPosition += yOffset; - } - } - - // update the last departure time if the current zone is longer - if (departureTime >= lastDepartureTime) lastDepartureTime = departureTime; - - drawOccupationZone(ctx, stcContext, { - zone, - position, - yZone: trackY + yPosition, - selectedTrainId, - }); - - zoneCounter++; - yOffset += Y_OFFSET_INCREMENT; - zoneIndex++; - - continue; - } - - const nextIndex = filteredOccupancyZones.findIndex( - (filteredZone, i) => i > zoneIndex && filteredZone.arrivalTime >= lastDepartureTime - ); - - const remainingTrainsNb = nextIndex - zoneIndex; - - const xPosition = - getTimePixel((primaryArrivalTime + lastDepartureTime) / 2) - REMAINING_TRAINS_WIDTH / 2; - - drawRemainingTrainsBox({ ctx, remainingTrainsNb, xPosition, yPosition: trackY }); - - zoneIndex += remainingTrainsNb; - } + yPosition: y, + isSelected, }); }; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts index d13fb4035ba..129e8e09752 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts @@ -28,7 +28,7 @@ export const drawOccupancyZonesTexts = ({ departureTimePixel, yPosition, isThroughTrain, - selectedTrainId, + isSelected, }: { ctx: CanvasRenderingContext2D; zone: OccupancyZone; @@ -36,7 +36,7 @@ export const drawOccupancyZonesTexts = ({ departureTimePixel: number; yPosition: number; isThroughTrain: boolean; - selectedTrainId?: string; + isSelected?: boolean; }) => { const zoneOccupancyLength = departureTimePixel - arrivalTimePixel - STROKE_WIDTH; @@ -66,12 +66,12 @@ export const drawOccupancyZonesTexts = ({ const xDeparturePosition = isBelowBreakpoint('small') ? 'left' : 'center'; const textStroke = { - color: selectedTrainId === zone.trainId ? 'transparent' : WHITE_100, + color: isSelected ? 'transparent' : WHITE_100, width: STROKE_WIDTH, }; // train name - if (selectedTrainId === zone.trainId) { + if (isSelected) { const { xSelectedTrainNameBackground, ySelectedTrainNameBackground } = isBelowBreakpoint( 'medium' ) diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx index eb280e01926..83024f5009b 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx @@ -1,8 +1,45 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; -import { useDraw, type DrawingFunction } from '../../../spaceTimeChart'; -import { drawOccupancyZones } from '../helpers/drawElements/drawOccupancyZones'; -import type { OccupancyZone, Track } from '../types'; +import { sortBy } from 'lodash'; + +import { + useDraw, + type DrawingFunction, + type PickingDrawingFunction, + usePicking, +} from '../../../spaceTimeChart'; +import { drawAliasedRect } from '../../../spaceTimeChart/utils/canvas'; +import { hexToRgb, indexToColor } from '../../../spaceTimeChart/utils/colors'; +import { + CANVAS_PADDING, + OCCUPANCY_ZONE_HEIGHT, + OCCUPANCY_ZONE_Y_START, + TRACK_HEIGHT_CONTAINER, +} from '../consts'; +import { + drawOccupationZone, + drawRemainingTrainsBox, +} from '../helpers/drawElements/drawOccupancyZones'; +import type { OccupancyZone, OccupancyZonePickingElement, Track } from '../types'; + +interface BaseRenderingInstruction { + type: string; + offsetY: number; +} +interface OccupancyZoneRenderingInstruction extends BaseRenderingInstruction { + type: 'occupancyZone'; + zone: OccupancyZone; + isSelected: boolean; +} +interface RemainingTrainsRenderingInstruction extends BaseRenderingInstruction { + type: 'remainingTrains'; + amount: number; + time: number; +} +type RenderingInstruction = OccupancyZoneRenderingInstruction | RemainingTrainsRenderingInstruction; + +const Y_OFFSET_INCREMENT = 4; +const MAX_ZONES = 9; const OccupancyZonesLayer = ({ tracks, @@ -17,19 +54,168 @@ const OccupancyZonesLayer = ({ topPadding: number; selectedTrainId?: string; }) => { + const zonesToDraw = useMemo(() => { + const instructions: RenderingInstruction[] = []; + + if (!tracks || !occupancyZones || occupancyZones.length === 0) return instructions; + + const sortedOccupancyZones = occupancyZones.sort((a, b) => a.arrivalTime - b.arrivalTime); + + tracks.forEach((track, index) => { + const trackY = topPadding + CANVAS_PADDING + index * TRACK_HEIGHT_CONTAINER; + + const filteredOccupancyZones = sortedOccupancyZones.filter( + (zone) => zone.trackId === track.id + ); + + let primaryArrivalTime = 0; + let primaryDepartureTime = 0; + let lastDepartureTime = primaryDepartureTime; + let yPosition = OCCUPANCY_ZONE_Y_START; + let yOffset = Y_OFFSET_INCREMENT; + let zoneCounter = 0; + let zoneIndex = 0; + + while (zoneIndex < filteredOccupancyZones.length) { + const zone = filteredOccupancyZones[zoneIndex]; + const { arrivalTime, departureTime } = zone; + + // * if the zone is not overlapping with any previous one, draw it in the center of the track + // * and reset the primary values + // * + // * if the zone is overlapping with the previous one, draw it below or above the previous one + // * depending on the overlapping counter + // * + // * if the zone is overlapping with the previous one and the counter is higher than the max zones + // * draw the remaining trains box + // * + if (arrivalTime > lastDepartureTime) { + // reset to initial value if the zone is not overlapping + yPosition = OCCUPANCY_ZONE_Y_START; + primaryArrivalTime = arrivalTime; + primaryDepartureTime = departureTime; + lastDepartureTime = departureTime; + yOffset = Y_OFFSET_INCREMENT; + zoneCounter = 1; + + instructions.push({ + type: 'occupancyZone', + zone, + offsetY: trackY + yPosition, + isSelected: zone.trainId === selectedTrainId, + }); + + zoneIndex++; + } + + // if so and it's an even index, move it to the bottom, if it's an odd index, move it to the top + else if (zoneCounter < MAX_ZONES) { + if (arrivalTime >= primaryArrivalTime) { + if (zoneCounter % 2 === 0) { + yPosition -= yOffset; + } else { + yPosition += yOffset; + } + } + + // update the last departure time if the current zone is longer + if (departureTime >= lastDepartureTime) lastDepartureTime = departureTime; + + instructions.push({ + type: 'occupancyZone', + zone, + offsetY: trackY + yPosition, + isSelected: zone.trainId === selectedTrainId, + }); + + zoneCounter++; + yOffset += Y_OFFSET_INCREMENT; + zoneIndex++; + } + + // else, if there are too much trains: + else { + const nextIndex = filteredOccupancyZones.findIndex( + (filteredZone, i) => i > zoneIndex && filteredZone.arrivalTime >= lastDepartureTime + ); + + const remainingTrainsNb = nextIndex - zoneIndex; + + instructions.push({ + type: 'remainingTrains', + amount: remainingTrainsNb, + time: (zone.arrivalTime + zone.departureTime) / 2, + offsetY: trackY, + }); + + zoneIndex += remainingTrainsNb; + } + } + }); + + return sortBy(instructions, (instruction) => + instruction.type === 'occupancyZone' && instruction.isSelected ? 0 : 1 + ); + }, [occupancyZones, selectedTrainId, topPadding, tracks]); + const drawingFunction = useCallback( (ctx, stcContext) => { - drawOccupancyZones(ctx, stcContext, { - tracks, - occupancyZones, - selectedTrainId, - position, - topPadding, + zonesToDraw.forEach((instruction) => { + switch (instruction.type) { + case 'occupancyZone': + drawOccupationZone(ctx, stcContext, { + zone: instruction.zone, + position, + yOffset: instruction.offsetY, + isSelected: instruction.isSelected, + }); + break; + case 'remainingTrains': + drawRemainingTrainsBox(ctx, stcContext, { + position, + time: instruction.time, + yOffset: instruction.offsetY, + remainingTrainsNb: instruction.amount, + }); + break; + } + }); + }, + [position, zonesToDraw] + ); + + const pickingFunction = useCallback( + (imageData, { registerPickingElement, getTimePixel, getSpacePixel }, scalingRatio) => { + zonesToDraw.forEach((instruction) => { + if (instruction.type === 'occupancyZone') { + const x = getTimePixel(instruction.zone.arrivalTime); + const y = instruction.offsetY + getSpacePixel(position); + const width = getTimePixel(instruction.zone.departureTime) - x; + const height = OCCUPANCY_ZONE_HEIGHT; + const margin = 6; + + const pickingElement: OccupancyZonePickingElement = { + type: 'occupancyZone', + trainId: instruction.zone.trainId, + }; + const pickingIndex = registerPickingElement(pickingElement); + const color = hexToRgb(indexToColor(pickingIndex)); + + drawAliasedRect( + imageData, + { x: x - margin, y: y - margin }, + width + 2 * margin, + height + 2 * margin, + color, + scalingRatio + ); + } }); }, - [occupancyZones, position, selectedTrainId, topPadding, tracks] + [position, zonesToDraw] ); + usePicking('overlay', pickingFunction); useDraw('overlay', drawingFunction); return null; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts index ca9678195d9..00569f4dfc2 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/types.ts @@ -20,4 +20,6 @@ export type OccupancyZone = { departureTime: number; }; +export type OccupancyZonePickingElement = { type: 'occupancyZone'; trainId: string }; + export type TickPattern = keyof typeof TICKS_PATTERN; From bc04fc7906f203e304a32c2fe66f7b562ed9b8a7 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Tue, 29 Apr 2025 16:43:58 +0200 Subject: [PATCH 09/13] ui-charts: adds top and bottom borders to TOD Details: - Adds top and bottom borders to TrackOccupancyCanvas - Adds top and bottom borders to TrackOccupancyManchette - Removes these borders when using TrackOccupancyStandalone Signed-off-by: Alexis Jacomy --- .../components/TrackOccupancyCanvas.tsx | 9 ++++++- .../components/TrackOccupancyStandalone.tsx | 1 + .../helpers/drawElements/drawTracks.ts | 24 ++++++++++++++++- .../components/layers/TracksLayer.tsx | 6 +++-- .../src/trackOccupancyDiagram/styles/main.css | 27 +++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx index 9bfc00deff3..4fa7276a5f1 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx @@ -32,6 +32,7 @@ const TrackOccupancyCanvas = ({ selectedTrainId, onClose, topPadding = 0, + hideBorders = false, }: { position: number; tracks: Track[]; @@ -39,9 +40,15 @@ const TrackOccupancyCanvas = ({ selectedTrainId?: string; onClose?: () => void; topPadding?: number; + hideBorders?: boolean; }) => ( <> - + ), manchetteNode: , diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts index 2841be6bb71..373039caae1 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts @@ -1,6 +1,6 @@ import { drawTrack } from './drawTrack'; import type { SpaceTimeChartContextType } from '../../../../spaceTimeChart'; -import { HOUR, MINUTE } from '../../../../spaceTimeChart/lib/consts'; +import { GREY_50, HOUR, MINUTE } from '../../../../spaceTimeChart/lib/consts'; import { TRACK_HEIGHT_CONTAINER, CANVAS_PADDING, COLORS, TICKS_PRIORITIES } from '../../consts'; import { type Track } from '../../types'; import { getLabelLevels, getLabelMarks } from '../../utils'; @@ -13,10 +13,12 @@ export const drawTracks = ( { position, tracks, + drawBorders, topPadding = 0, }: { position: number; tracks: Track[]; + drawBorders: boolean; topPadding: number; } ) => { @@ -38,6 +40,7 @@ export const drawTracks = ( const labelLevels = getLabelLevels(breakpoints, pixelsPerMinute, TICKS_PRIORITIES); const labelMarks = getLabelMarks(timeRanges, timeStart, timeEnd, labelLevels); + // Draw backgrounds: let hours = Math.floor(timeStart / HOUR); const hourEnd = timeEnd / HOUR; while (hours < hourEnd) { @@ -48,6 +51,7 @@ export const drawTracks = ( hours++; } + // Draw actual tracks: ctx.save(); ctx.translate(0, yStart + topPadding); tracks?.forEach((_, index) => { @@ -61,4 +65,22 @@ export const drawTracks = ( }); }); ctx.restore(); + + // Draw borders: + if (drawBorders) { + const externalBorderWidth = 1; + const internalBorderWidth = 2; + const fullBorderWidth = externalBorderWidth + internalBorderWidth; + const yStartCrisp = Math.round(yStart); + const yEndCrisp = Math.round(yEnd); + ctx.fillStyle = GREY_50; + ctx.fillRect(0, yStartCrisp, width, externalBorderWidth); + ctx.fillRect(0, yEndCrisp - externalBorderWidth, width, externalBorderWidth); + + ctx.fillStyle = GREY_50; + ctx.globalAlpha = 0.15; + ctx.fillRect(0, yStartCrisp + externalBorderWidth, width, internalBorderWidth); + ctx.fillRect(0, yEndCrisp - fullBorderWidth, width, internalBorderWidth); + ctx.globalAlpha = 1; + } }; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx index ea12192df9e..00fe4204086 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/TracksLayer.tsx @@ -8,16 +8,18 @@ const TracksLayer = ({ tracks, position, topPadding, + drawBorders, }: { tracks: Track[]; position: number; topPadding: number; + drawBorders: boolean; }) => { const drawingFunction = useCallback( (ctx, stcContext) => { - drawTracks(ctx, stcContext, { position, topPadding, tracks }); + drawTracks(ctx, stcContext, { position, topPadding, tracks, drawBorders }); }, - [position, topPadding, tracks] + [drawBorders, position, topPadding, tracks] ); useDraw('overlay', drawingFunction); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css b/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css index bb8115925a3..8f90661dde1 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css @@ -8,6 +8,28 @@ border-radius: inherit; padding-top: 10px; + position: relative; + &::before, + &::after { + content: ''; + position: absolute; + width: 100%; + left: 0; + height: 3px; + box-sizing: border-box; + background-color: color-mix(in oklab, theme('colors.grey.50') 15%, transparent); + border-color: theme('colors.grey.50'); + border-style: solid; + } + &::before { + top: 0; + border-top-width: 1px; + } + &::after { + bottom: 0; + border-bottom-width: 1px; + } + > canvas { border-radius: inherit; } @@ -81,6 +103,11 @@ inset 0 1px 0 0 rgb(255, 255, 255); border-radius: 10px; + .track-occupancy-manchette::before, + .track-occupancy-manchette::after { + display: none; + } + .manchette-space-time-chart-wrapper { overflow: auto; flex: 1; From 9312e392cef7ae3806c3056189348d7b71117e94 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Mon, 12 May 2025 16:46:48 +0200 Subject: [PATCH 10/13] ui-charts: integrates various minor changes This commit addresses various discussions opened by reviewers on PR https://github.com/OpenRailAssociation/osrd/pull/11636 Details: - Adds train selection in track-occupancy.stories.tsx - Fixes "+XXX trains" label placement in OccupancyZonesLayer - Adds various comments and renames some variables and functions to improve code readability Signed-off-by: Alexis Jacomy --- .../track-occupancy.stories.tsx | 45 ++++++++++++++-- .../spaceTimeChart/helpers/components.tsx | 8 +-- .../spaceTimeChart/helpers/consts.ts | 2 +- .../ui-charts/spaceTimeChart/helpers/paths.ts | 12 ++--- .../horizontal-zoom.stories.tsx | 4 +- .../spaceTimeChart/rectangle-zoom.stories.tsx | 2 + .../rendering.stories.tsx | 5 +- .../hooks/useManchetteWithSpaceTimeChart.tsx | 5 +- .../spaceTimeChart/components/PathLayer.tsx | 8 +-- .../src/spaceTimeChart/utils/scales.ts | 2 +- .../drawElements/drawOccupancyZones.ts | 2 +- .../helpers/drawElements/drawTracks.ts | 4 +- .../components/layers/OccupancyZonesLayer.tsx | 51 ++++++++++--------- 13 files changed, 94 insertions(+), 56 deletions(-) diff --git a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx index 698ec511c3d..921f77a8d67 100644 --- a/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx @@ -12,13 +12,16 @@ import { type OccupancyZone, type Track, isInteractiveWaypoint, + type OccupancyZonePickingElement, + isPointPickingElement, + isSegmentPickingElement, } from '@osrd-project/ui-charts'; import '@osrd-project/ui-charts/dist/theme.css'; import '@osrd-project/ui-core/dist/theme.css'; import type { Meta } from '@storybook/react'; import { - getOccupancyZonesFromPath, + getOccupancyZonesFromPathAtGivenWaypoint, OPERATIONAL_POINTS, PATHS, } from '../spaceTimeChart/helpers/paths'; @@ -36,7 +39,7 @@ const BASE_WAYPOINT_HEIGHT = 32; */ const TrackOccupancyDiagramWithinSpaceTimeChartWrapper = ({ height = 561 }: { height: number }) => { // TODO: Restore trains selection from GOV - const [selectedTrain, _setSelectedTrain] = useState(undefined); + const [selectedTrain, setSelectedTrain] = useState(); const [selectedWaypoint, setSelectedWaypoint] = useState( OPERATIONAL_POINTS[2].id ); @@ -56,7 +59,7 @@ const TrackOccupancyDiagramWithinSpaceTimeChartWrapper = ({ height = 561 }: { he { id: '3', name: '2bis', line: 'line' }, ]; const occupancyZones: OccupancyZone[] = paths.flatMap((path, i) => - getOccupancyZonesFromPath(path.points, operationalPoint.position, { + getOccupancyZonesFromPathAtGivenWaypoint(path.points, operationalPoint.position, { trainId: path.id, trackId: tracks[i % tracks.length].id, // (i.e. pick some random track) arrivalTrainName: 'foo', @@ -131,9 +134,41 @@ const TrackOccupancyDiagramWithinSpaceTimeChartWrapper = ({ height = 561 }: { he )} />
- + { + // Handle clicking the occupancyZone items (on the TrackOccupancyCanvas layer): + if ( + hoveredItem?.layer === 'overlay' && + hoveredItem.element.type === 'occupancyZone' + ) { + const newId = (hoveredItem.element as OccupancyZonePickingElement).trainId; + setSelectedTrain(newId === selectedTrain ? undefined : newId); + } + + // Handle clicking the path items (on the Path layers): + else if ( + hoveredItem?.layer === 'paths' && + (isPointPickingElement(hoveredItem.element) || + isSegmentPickingElement(hoveredItem.element)) + ) { + const newId = hoveredItem.element.pathId; + setSelectedTrain(newId === selectedTrain ? undefined : newId); + } + // Handle clicking the stage: + else { + setSelectedTrain(undefined); + } + }} + > {paths.map((path) => ( - + ))}
diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx index 1d9036ea410..3bf118cc61f 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx @@ -5,10 +5,10 @@ import { SpaceTimeChartContext, type DataPoint, type Point, + positionMmToKm, } from '@osrd-project/ui-charts'; -import { round } from 'lodash'; -import { KILOMETER, WHITE_75 } from './consts'; +import { WHITE_75 } from './consts'; import { formatTimeLength } from './utils'; /** @@ -90,12 +90,12 @@ const DataLabel = ({ {isDiff ? ( <>
Time difference: {formatTimeLength(new Date(data.time))}
-
Distance to mark: {round(data.position / KILOMETER).toLocaleString()} km
+
Distance to mark: {positionMmToKm(data.position).toLocaleString()} km
) : ( <>
Time: {new Date(data.time).toLocaleTimeString()}
-
Distance: {round(data.position / KILOMETER).toLocaleString()} km
+
Distance: {positionMmToKm(data.position).toLocaleString()} km
)}
diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts index 52fbfc7716e..a97e7af359b 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/consts.ts @@ -19,5 +19,5 @@ export const SECOND = 1000; export const MINUTE = 60 * SECOND; export const HOUR = 60 * MINUTE; -// Same for distances in meters: +// Same for distances in millimeters: export const KILOMETER = 1000000; diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts index d251ed724b4..c25ad4066b9 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts @@ -1,9 +1,9 @@ import { - DataPoint, - OccupancyZone, - OperationalPoint, - PathData, - PathLevel, + type DataPoint, + type OccupancyZone, + type OperationalPoint, + type PathData, + type PathLevel, } from '@osrd-project/ui-charts'; import { cloneDeep, inRange, keyBy } from 'lodash'; @@ -247,7 +247,7 @@ export const PATHS: PathDisplay[] = [ ), ]; -export function getOccupancyZonesFromPath( +export function getOccupancyZonesFromPathAtGivenWaypoint( points: DataPoint[], waypointPosition: number, additionalAttributes: T diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx index d3d55bb848d..66a611fa622 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx @@ -71,11 +71,11 @@ const SpaceTimeHorizontalZoomWrapper = ({ })); const spaceScale = [ { - from: 0, - to: 75000, + to: 75000000, coefficient: 300000, }, ]; + return (
{ - const [selectedTrainId, setSelectedTrainId] = useState(undefined); + const [selectedTrainId, setSelectedTrainId] = useState(); useEffect(() => { setSelectedTrainId(`${trainId}`); diff --git a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx index d45489bb588..d36879b64f7 100644 --- a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx +++ b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx @@ -683,10 +683,11 @@ const useManchetteWithSpaceTimeChart = ({ if (enableSpacePan) { let newYOffset = initialOffset.y - diff.y; newYOffset = Math.max(newYOffset, 0); - if (manchette) + if (manchette) { newYOffset = Math.min(newYOffset, manchette.scrollHeight - manchette.offsetHeight); + manchette.scrollTop = newYOffset; + } newState.yOffset = newYOffset; - if (manchette) manchette.scrollTop = newYOffset; } return newState; diff --git a/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx b/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx index 2da7bbaffbf..547c13a55ca 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx +++ b/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx @@ -129,7 +129,7 @@ export const PathLayer = ({ for (let i = 0; i < points.length; i++) { const { position, time } = points[i]; - if (!i) { + if (i === 0) { line.push({ [timeAxis]: getTimePixel(time), [spaceAxis]: getSpacePixel(position), @@ -373,8 +373,8 @@ export const PathLayer = ({ }); // Compute length of pathSegments - lines.forEach((points) => { - points.forEach(({ x, y }, i, a) => { + lines.forEach((line) => { + line.forEach(({ x, y }, i, a) => { if (i > 0) { const { x: prevX, y: prevY } = a[i - 1]; totalLength += Math.sqrt(Math.pow(prevX - x, 2) + Math.pow(prevY - y, 2)); @@ -445,7 +445,7 @@ export const PathLayer = ({ lines.forEach((points) => { ctx.beginPath(); points.forEach(({ x, y }, i) => { - if (!i) { + if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); diff --git a/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts b/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts index c817afa1216..1213c336008 100644 --- a/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts +++ b/front/ui/ui-charts/src/spaceTimeChart/utils/scales.ts @@ -195,7 +195,7 @@ export function getSpaceToPixel( ); // Rare case where coefficient is 0: // (occurs when there is just a flat step, for instance) - if (!coefficient) return pixelOffset + (fromEnd ? pixelTo : pixelFrom); + if (coefficient === 0) return pixelOffset + (fromEnd ? pixelTo : pixelFrom); // Normal case: We simply interpolate return pixelOffset + pixelFrom + (position - from) / coefficient; diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts index 55618a77214..d3837833b33 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZones.ts @@ -145,7 +145,7 @@ export const drawOccupationZone = ( }); } - // Draw trains: + // Draw dashed lines linking trains tracks occupancy to their paths on the SpaceTimeChart (when relevant): ctx.strokeStyle = zone.color; ctx.lineWidth = 1; ctx.setLineDash([1, 4]); diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts index 373039caae1..4ddf4f79f18 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawTracks.ts @@ -32,7 +32,7 @@ export const drawTracks = ( } = stcContext; const yStart = getSpacePixel(position); const yEnd = getSpacePixel(position, true); - const height = yEnd - yStart; + const flatStepHeight = yEnd - yStart; const timeStart = getTime(0); const timeEnd = getTime(width); const pixelsPerMinute = (1 / timeScale) * MINUTE; @@ -47,7 +47,7 @@ export const drawTracks = ( const x = getTimePixel(hours * HOUR); const w = getTimePixel((hours + 1) * HOUR) - x; ctx.fillStyle = hours % 2 ? HOUR_BACKGROUND_1 : HOUR_BACKGROUND_2; - ctx.fillRect(x, yStart, w, height); + ctx.fillRect(x, yStart, w, flatStepHeight); hours++; } diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx index 83024f5009b..741d8278fd1 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx @@ -22,21 +22,19 @@ import { } from '../helpers/drawElements/drawOccupancyZones'; import type { OccupancyZone, OccupancyZonePickingElement, Track } from '../types'; -interface BaseRenderingInstruction { - type: string; - offsetY: number; -} -interface OccupancyZoneRenderingInstruction extends BaseRenderingInstruction { - type: 'occupancyZone'; - zone: OccupancyZone; - isSelected: boolean; -} -interface RemainingTrainsRenderingInstruction extends BaseRenderingInstruction { - type: 'remainingTrains'; - amount: number; - time: number; -} -type RenderingInstruction = OccupancyZoneRenderingInstruction | RemainingTrainsRenderingInstruction; +type RenderingInstruction = + | { + type: 'occupancyZone'; + zone: OccupancyZone; + isSelected: boolean; + offsetY: number; + } + | { + type: 'remainingTrains'; + amount: number; + time: number; + offsetY: number; + }; const Y_OFFSET_INCREMENT = 4; const MAX_ZONES = 9; @@ -54,7 +52,7 @@ const OccupancyZonesLayer = ({ topPadding: number; selectedTrainId?: string; }) => { - const zonesToDraw = useMemo(() => { + const instructionsToDraw = useMemo(() => { const instructions: RenderingInstruction[] = []; if (!tracks || !occupancyZones || occupancyZones.length === 0) return instructions; @@ -69,8 +67,7 @@ const OccupancyZonesLayer = ({ ); let primaryArrivalTime = 0; - let primaryDepartureTime = 0; - let lastDepartureTime = primaryDepartureTime; + let lastDepartureTime = 0; let yPosition = OCCUPANCY_ZONE_Y_START; let yOffset = Y_OFFSET_INCREMENT; let zoneCounter = 0; @@ -93,7 +90,6 @@ const OccupancyZonesLayer = ({ // reset to initial value if the zone is not overlapping yPosition = OCCUPANCY_ZONE_Y_START; primaryArrivalTime = arrivalTime; - primaryDepartureTime = departureTime; lastDepartureTime = departureTime; yOffset = Y_OFFSET_INCREMENT; zoneCounter = 1; @@ -140,11 +136,14 @@ const OccupancyZonesLayer = ({ ); const remainingTrainsNb = nextIndex - zoneIndex; + const remainingZones = filteredOccupancyZones.slice(zoneIndex, nextIndex); + const minTime = Math.min(...remainingZones.map((z) => z.arrivalTime)); + const maxTime = Math.max(...remainingZones.map((z) => z.departureTime)); instructions.push({ type: 'remainingTrains', amount: remainingTrainsNb, - time: (zone.arrivalTime + zone.departureTime) / 2, + time: (minTime + maxTime) / 2, offsetY: trackY, }); @@ -160,7 +159,7 @@ const OccupancyZonesLayer = ({ const drawingFunction = useCallback( (ctx, stcContext) => { - zonesToDraw.forEach((instruction) => { + instructionsToDraw.forEach((instruction) => { switch (instruction.type) { case 'occupancyZone': drawOccupationZone(ctx, stcContext, { @@ -181,15 +180,17 @@ const OccupancyZonesLayer = ({ } }); }, - [position, zonesToDraw] + [position, instructionsToDraw] ); const pickingFunction = useCallback( (imageData, { registerPickingElement, getTimePixel, getSpacePixel }, scalingRatio) => { - zonesToDraw.forEach((instruction) => { + const flatStepOffsetY = getSpacePixel(position); + + instructionsToDraw.forEach((instruction) => { if (instruction.type === 'occupancyZone') { const x = getTimePixel(instruction.zone.arrivalTime); - const y = instruction.offsetY + getSpacePixel(position); + const y = instruction.offsetY + flatStepOffsetY; const width = getTimePixel(instruction.zone.departureTime) - x; const height = OCCUPANCY_ZONE_HEIGHT; const margin = 6; @@ -212,7 +213,7 @@ const OccupancyZonesLayer = ({ } }); }, - [position, zonesToDraw] + [position, instructionsToDraw] ); usePicking('overlay', pickingFunction); From 933a4566f7ffa215e43cab49cff214672ee2d729 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Mon, 12 May 2025 17:16:47 +0200 Subject: [PATCH 11/13] ui-charts: draws times in TrackOccupancyStandalone This commit adds times graduations in the bottom of the TrackOccupancyStandalone component. Signed-off-by: Alexis Jacomy --- .../components/TrackOccupancyStandalone.tsx | 11 +++++++---- .../src/trackOccupancyDiagram/styles/main.css | 9 ++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx index a0ab03f0320..c2e72b36146 100644 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx @@ -7,7 +7,7 @@ import TrackOccupancyCanvas from './TrackOccupancyCanvas'; import TrackOccupancyManchette from './TrackOccupancyManchette'; import type { OccupancyZone, OccupancyZonePickingElement, Track } from './types'; import { Manchette, useManchetteWithSpaceTimeChart } from '../../manchette'; -import { SpaceTimeChart } from '../../spaceTimeChart'; +import { DEFAULT_THEME, SpaceTimeChart } from '../../spaceTimeChart'; import { HOUR } from '../../spaceTimeChart/lib/consts'; const TrackOccupancyStandalone = ({ @@ -15,7 +15,7 @@ const TrackOccupancyStandalone = ({ occupancyZones, selectedTrainId, onSelectedTrainIdChange, - height = TRACK_HEIGHT_CONTAINER * tracks.length, + height = TRACK_HEIGHT_CONTAINER * tracks.length + DEFAULT_THEME.timeCaptionsSize, }: { tracks: Track[]; occupancyZones: OccupancyZone[]; @@ -46,7 +46,10 @@ const TrackOccupancyStandalone = ({ { id: 'ACTUAL_TRACK_OCCUPANCY_DIAGRAM', position: 0, - size: Math.max(height, tracks.length * TRACK_HEIGHT_CONTAINER), + size: Math.max( + height, + tracks.length * TRACK_HEIGHT_CONTAINER + DEFAULT_THEME.timeCaptionsSize + ), spaceTimeChartNode: ( Date: Tue, 13 May 2025 15:45:10 +0200 Subject: [PATCH 12/13] ui-charts: fixes shift+wheel on SpaceTimeChart Signed-off-by: Alexis Jacomy --- .../src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx index d36879b64f7..b80724de9ea 100644 --- a/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx +++ b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx @@ -612,8 +612,10 @@ const useManchetteWithSpaceTimeChart = ({ onZoom: ({ delta, position, + event, }: Parameters>[0]) => { if (isShiftPressed && !rect) { + event.preventDefault(); handleXZoom(xZoom + delta, position.x); } }, From 5a9e83e00d67744a4b2d7d4f1654c5a1be34b4d4 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Tue, 13 May 2025 16:42:19 +0200 Subject: [PATCH 13/13] ui-charts: integrates various minor changes This commit addresses various discussions opened by reviewers on PR https://github.com/OpenRailAssociation/osrd/pull/11636 Details: - Fixes "+XXX trains" label placement in OccupancyZonesLayer, so that it's properly centered on displayed zones of the pack - Replaces `XXX * KILOMETER` with `positionKmToMm(XXX)` for clarity Signed-off-by: Alexis Jacomy --- .../ui-charts/spaceTimeChart/helpers/paths.ts | 31 +++++++++---------- front/ui/ui-charts/src/manchette/index.ts | 2 +- .../ui/ui-charts/src/manchette/utils/index.ts | 2 ++ .../components/layers/OccupancyZonesLayer.tsx | 5 +-- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts index c25ad4066b9..d24adc345cb 100644 --- a/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts +++ b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/paths.ts @@ -4,11 +4,10 @@ import { type OperationalPoint, type PathData, type PathLevel, + positionKmToMm, } from '@osrd-project/ui-charts'; import { cloneDeep, inRange, keyBy } from 'lodash'; -import { KILOMETER } from './consts'; - const MIN = 60 * 1000; export function getPaths( @@ -71,37 +70,37 @@ export const OPERATIONAL_POINTS: OperationalPoint[] = [ { id: 'city-a', label: 'Point A', - position: 0 * KILOMETER, + position: positionKmToMm(0), importanceLevel: 1, }, { id: 'city-b', label: 'Point B', - position: 10 * KILOMETER, + position: positionKmToMm(10), importanceLevel: 2, }, { id: 'city-c', label: 'Point C', - position: 60 * KILOMETER, + position: positionKmToMm(60), importanceLevel: 1, }, { id: 'city-d', label: 'Point D', - position: 70 * KILOMETER, + position: positionKmToMm(70), importanceLevel: 2, }, { id: 'city-e', label: 'Point E', - position: 90 * KILOMETER, + position: positionKmToMm(90), importanceLevel: 2, }, { id: 'city-f', label: 'Point F', - position: 140 * KILOMETER, + position: positionKmToMm(140), importanceLevel: 1, }, ]; @@ -146,7 +145,7 @@ export const PATHS: PathDisplay[] = [ OPERATIONAL_POINTS, 3 * MIN, 60 * MIN, - (80 * KILOMETER) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 2, +START_DATE + 10 * MIN, { @@ -163,7 +162,7 @@ export const PATHS: PathDisplay[] = [ OPERATIONAL_POINTS, 3 * MIN, 60 * MIN, - (80 * KILOMETER) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 1, +START_DATE + 40 * MIN, { @@ -182,7 +181,7 @@ export const PATHS: PathDisplay[] = [ OPERATIONAL_POINTS, 3 * MIN, 30 * MIN, - (80 * KILOMETER) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 5, +START_DATE, { color: '#FF362E' } @@ -192,7 +191,7 @@ export const PATHS: PathDisplay[] = [ REVERSED_POINTS, 3 * MIN, 35 * MIN, - (80 * KILOMETER) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 4, +START_DATE, { color: '#FF8E3D' } @@ -204,7 +203,7 @@ export const PATHS: PathDisplay[] = [ EXTREME_POINTS, 5 * MIN, 50 * MIN, - (140 * KILOMETER) / (60 * MIN), + positionKmToMm(140) / (60 * MIN), 3, +START_DATE, { @@ -218,7 +217,7 @@ export const PATHS: PathDisplay[] = [ REVERSED_EXTREME_POINTS, 5 * MIN, 45 * MIN, - (140 * KILOMETER) / (60 * MIN), + positionKmToMm(140) / (60 * MIN), 3, +START_DATE, { color: '#66C0F1', fromEnd: 'out', toEnd: 'out' } @@ -230,7 +229,7 @@ export const PATHS: PathDisplay[] = [ BACK_AND_FORTH_POINTS, 10 * MIN, 30 * MIN, - (80 * KILOMETER) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 2, +START_DATE + 15 * MIN, { color: '#286109', toEnd: 'out' } @@ -240,7 +239,7 @@ export const PATHS: PathDisplay[] = [ REVERSED_BACK_AND_FORTH_POINTS, 12 * MIN, 30 * MIN, - (80 * KILOMETER) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 2, +START_DATE + 3 * MIN, { color: '#64cc2b', toEnd: 'out' } diff --git a/front/ui/ui-charts/src/manchette/index.ts b/front/ui/ui-charts/src/manchette/index.ts index d7c2f714cc6..60f26f11a76 100644 --- a/front/ui/ui-charts/src/manchette/index.ts +++ b/front/ui/ui-charts/src/manchette/index.ts @@ -16,5 +16,5 @@ export { default as usePaths } from './hooks/usePaths'; export type { Waypoint, ProjectPathTrainResult, InteractiveWaypoint } from './types'; -export { positionMmToKm } from './utils'; +export { positionMmToKm, positionKmToMm } from './utils'; export { timeScaleToZoomValue, isInteractiveWaypoint } from './utils/helpers'; diff --git a/front/ui/ui-charts/src/manchette/utils/index.ts b/front/ui/ui-charts/src/manchette/utils/index.ts index 94217e8c582..cac7253f871 100644 --- a/front/ui/ui-charts/src/manchette/utils/index.ts +++ b/front/ui/ui-charts/src/manchette/utils/index.ts @@ -5,6 +5,8 @@ export const getHeightWithoutLastWaypoint = (height: number) => export const positionMmToKm = (position: number) => Math.round((position / 1000000) * 10) / 10; +export const positionKmToMm = (position: number) => position * 1000000; + export const msToS = (time: number) => time / 1000; export const calcTotalDistance = (ops: { position: number }[]) => { diff --git a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx index 741d8278fd1..89815cd3993 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx @@ -136,14 +136,11 @@ const OccupancyZonesLayer = ({ ); const remainingTrainsNb = nextIndex - zoneIndex; - const remainingZones = filteredOccupancyZones.slice(zoneIndex, nextIndex); - const minTime = Math.min(...remainingZones.map((z) => z.arrivalTime)); - const maxTime = Math.max(...remainingZones.map((z) => z.departureTime)); instructions.push({ type: 'remainingTrains', amount: remainingTrainsNb, - time: (minTime + maxTime) / 2, + time: (arrivalTime + departureTime) / 2, offsetY: trackY, });