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/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/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/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) => ( - + ))}
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..921f77a8d67 --- /dev/null +++ b/front/ui/storybook/stories/ui-charts/manchetteWithSpaceTimeChart/track-occupancy.stories.tsx @@ -0,0 +1,189 @@ +import React, { useMemo, useRef, useState } from 'react'; + +import { + Manchette, + PathLayer, + SpaceTimeChart, + TrackOccupancyCanvas, + TrackOccupancyManchette, + useManchetteWithSpaceTimeChart, + WaypointComponent, + TRACK_HEIGHT_CONTAINER, + 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 { + getOccupancyZonesFromPathAtGivenWaypoint, + 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(); + 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) => + getOccupancyZonesFromPathAtGivenWaypoint(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 + )} + /> +
+ { + // 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) => ( + + ))} + +
+
+
+ ); +}; + +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/components.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/helpers/components.tsx index 1444d444486..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,8 +5,8 @@ import { SpaceTimeChartContext, type DataPoint, type Point, + positionMmToKm, } from '@osrd-project/ui-charts'; -import { round } from 'lodash'; 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).toLocaleString()} m
+
Distance to mark: {positionMmToKm(data.position).toLocaleString()} km
) : ( <>
Time: {new Date(data.time).toLocaleTimeString()}
-
Distance: {round(data.position).toLocaleString()} m
+
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 01d7b110724..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: -export const KILOMETER = 1000; +// 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 2ba20676416..d24adc345cb 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,13 @@ -import type { OperationalPoint, PathData, PathLevel } from '@osrd-project/ui-charts'; -import { keyBy } from 'lodash'; +import { + type DataPoint, + type OccupancyZone, + type OperationalPoint, + type PathData, + type PathLevel, + positionKmToMm, +} from '@osrd-project/ui-charts'; +import { cloneDeep, inRange, keyBy } from 'lodash'; -const KM = 1000; const MIN = 60 * 1000; export function getPaths( @@ -64,37 +70,37 @@ export const OPERATIONAL_POINTS: OperationalPoint[] = [ { id: 'city-a', label: 'Point A', - position: 0 * KM, + position: positionKmToMm(0), importanceLevel: 1, }, { id: 'city-b', label: 'Point B', - position: 10 * KM, + position: positionKmToMm(10), importanceLevel: 2, }, { id: 'city-c', label: 'Point C', - position: 60 * KM, + position: positionKmToMm(60), importanceLevel: 1, }, { id: 'city-d', label: 'Point D', - position: 70 * KM, + position: positionKmToMm(70), importanceLevel: 2, }, { id: 'city-e', label: 'Point E', - position: 90 * KM, + position: positionKmToMm(90), importanceLevel: 2, }, { id: 'city-f', label: 'Point F', - position: 140 * KM, + position: positionKmToMm(140), importanceLevel: 1, }, ]; @@ -122,7 +128,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,14 +137,15 @@ export const PATHS: (PathData & { backgroundColor?: string; }; level?: PathLevel; -})[] = [ +}; +export const PATHS: PathDisplay[] = [ // Paced Train ...getPaths( 'Paced', OPERATIONAL_POINTS, 3 * MIN, 60 * MIN, - (80 * KM) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 2, +START_DATE + 10 * MIN, { @@ -155,7 +162,7 @@ export const PATHS: (PathData & { OPERATIONAL_POINTS, 3 * MIN, 60 * MIN, - (80 * KM) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 1, +START_DATE + 40 * MIN, { @@ -174,7 +181,7 @@ export const PATHS: (PathData & { OPERATIONAL_POINTS, 3 * MIN, 30 * MIN, - (80 * KM) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 5, +START_DATE, { color: '#FF362E' } @@ -184,24 +191,33 @@ export const PATHS: (PathData & { REVERSED_POINTS, 3 * MIN, 35 * MIN, - (80 * KM) / (60 * MIN), + positionKmToMm(80) / (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, + positionKmToMm(140) / (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), + positionKmToMm(140) / (60 * MIN), 3, +START_DATE, { color: '#66C0F1', fromEnd: 'out', toEnd: 'out' } @@ -213,7 +229,7 @@ export const PATHS: (PathData & { BACK_AND_FORTH_POINTS, 10 * MIN, 30 * MIN, - (80 * KM) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 2, +START_DATE + 15 * MIN, { color: '#286109', toEnd: 'out' } @@ -223,9 +239,74 @@ export const PATHS: (PathData & { REVERSED_BACK_AND_FORTH_POINTS, 12 * MIN, 30 * MIN, - (80 * KM) / (60 * MIN), + positionKmToMm(80) / (60 * MIN), 2, +START_DATE + 3 * MIN, { color: '#64cc2b', toEnd: 'out' } ), ]; + +export function getOccupancyZonesFromPathAtGivenWaypoint( + 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/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx b/front/ui/storybook/stories/ui-charts/spaceTimeChart/horizontal-zoom.stories.tsx index ee686c90ad4..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, - coefficient: 300, + to: 75000000, + coefficient: 300000, }, ]; + return (
({ 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..b414d249ed8 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,18 +21,21 @@ 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; const MIN_ZOOM = 0; const MAX_ZOOM = 100; +// The time zoom boundaries are expressed in milliseconds / pixel: 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; +// The space zoom boundaries are expressed in millimeters / pixel: +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 +53,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 +86,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 +98,7 @@ const RectangleZoomWrapper = ({ const timeScale = zoomValueToTimeScale(state.timeZoomValue); const spaceScale: SpaceScale[] = [ { - to: 100000, + to: 100 * KILOMETER, coefficient: zoomValueToSpaceScale(state.spaceZoomValue), // meter/px }, ]; @@ -122,7 +124,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 +229,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 ( 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..05a237f3700 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 './assets/tracks'; -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(); 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/manchette/hooks/useManchetteWithSpaceTimeChart.tsx b/front/ui/ui-charts/src/manchette/hooks/useManchetteWithSpaceTimeChart.tsx index c7b2571b3f1..b80724de9ea 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'; @@ -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: @@ -433,15 +470,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); @@ -450,25 +487,22 @@ 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) - return { - manchetteHeight: totalManchetteHeight, - manchetteContents: waypointsWithoutSplitPoints, - }; - // 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', @@ -510,12 +544,12 @@ const useManchetteWithSpaceTimeChart = ({ finalContents.push(
({ + operationalPoints: waypointsToDisplay.map((waypoint) => ({ ...waypoint, importanceLevel: 1, })), - additionalChildren: splitPoints.map((sp) => sp.spaceTimeChartNode), + additionalChildren: splitPoints.map((sp, i) => ( + {sp.spaceTimeChartNode} + )), + hideTimeCaptions: !displayTimeCaptions, timeScale: zoomValueToTimeScale(xZoom), xOffset, - yOffset: -yOffset + WAYPOINTS_OFFSET, + yOffset: -yOffset + verticalPadding, timeOrigin, spaceOrigin, spaceScales, onZoom: ({ delta, position, + event, }: Parameters>[0]) => { if (isShiftPressed && !rect) { + event.preventDefault(); handleXZoom(xZoom + delta, position.x); } }, @@ -638,18 +677,21 @@ 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); + manchette.scrollTop = newYOffset; + } + newState.yOffset = newYOffset; } + return newState; }); }, @@ -674,13 +716,14 @@ const useManchetteWithSpaceTimeChart = ({ yZoom, isProportional, yOffset, - waypointsWithoutSplitPoints, + waypointsToDisplay, + splitPoints, xZoom, xOffset, + verticalPadding, timeOrigin, spaceOrigin, spaceScales, - splitPoints, handleScroll, handleXZoom, toggleZoomMode, @@ -689,10 +732,13 @@ const useManchetteWithSpaceTimeChart = ({ minZoomMillimeterPerPx, maxZoomMillimeterPerPx, setTimeOrigin, + displayTimeCaptions, isShiftPressed, panning, - manchetteWithSpaceTimeChartRef, + enableTimePan, + enableSpacePan, canvasDrawingHeight, + manchetteWithSpaceTimeChartRef, ] ); }; diff --git a/front/ui/ui-charts/src/manchette/index.ts b/front/ui/ui-charts/src/manchette/index.ts index 50dac4150e4..60f26f11a76 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, @@ -15,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/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) => { 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/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/PathLayer.tsx b/front/ui/ui-charts/src/spaceTimeChart/components/PathLayer.tsx index a9fd0788e4a..547c13a55ca 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) => { - if (!i) { - res.push({ + }: 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 === 0) { + 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((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)); + } + }); }); 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 === 0) { + 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..ac370687ef0 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, @@ -43,6 +44,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { children, additionalChildren, enableSnapping, + hideTimeCaptions, hideGrid, hidePathsLabels, showTicks, @@ -75,6 +77,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { xOffset, yOffset, swapAxis, + hideTimeCaptions, hideGrid, hidePathsLabels, showTicks, @@ -91,6 +94,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { xOffset, yOffset, swapAxis, + hideTimeCaptions, hideGrid, hidePathsLabels, showTicks, @@ -100,6 +104,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 +152,7 @@ export const SpaceTimeChart = (props: SpaceTimeChartProps) => { operationalPoints, spaceOrigin, spaceScaleTree, + flatSteps, timeOrigin, timeScale, timePixelOffset, @@ -155,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 3c9f1a5bc86..51017edda6d 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; @@ -95,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]; @@ -204,6 +202,7 @@ export type SpaceTimeChartProps = { enableSnapping?: boolean; // Additional options to show/hide context information: + hideTimeCaptions?: boolean; hideGrid?: boolean; hidePathsLabels?: boolean; hideDates?: boolean; @@ -247,8 +246,6 @@ export type SpaceTimeChartProps = { export type SpaceTimeChartContextType = { width: number; height: number; - trackOccupancyWidth?: number; - trackOccupancyHeight?: number; // Axis-swapping related data: timeAxis: Axis; @@ -270,6 +267,7 @@ export type SpaceTimeChartContextType = { timeScale: number; spaceOrigin: number; spaceScaleTree: NormalizedScaleTree; + flatSteps: Set; // Translation helpers: getTimePixel: TimeToPixel; @@ -281,8 +279,6 @@ export type SpaceTimeChartContextType = { // Useful data: operationalPoints: OperationalPoint[]; - tracks?: Track[]; - occupancyZones?: OccupancyZone[]; // Full theme: theme: SpaceTimeChartTheme; @@ -290,6 +286,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 4d91f8eed4d..1213c336008 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. @@ -172,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 === 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/TrackOccupancyCanvas.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx index 36a243754e9..4fa7276a5f1 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyCanvas.tsx @@ -1,30 +1,63 @@ -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 TrackOccupancyCanvasProps } from './types'; +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 = ({ - opId, - useDraw, - setCanvasesRoot, + position, + tracks, + occupancyZones, selectedTrainId, - setSelectedTrainId, - mousePosition, -}: TrackOccupancyCanvasProps) => ( -
- + onClose, + topPadding = 0, + hideBorders = false, +}: { + position: number; + tracks: Track[]; + occupancyZones: OccupancyZone[]; + selectedTrainId?: string; + onClose?: () => void; + topPadding?: number; + hideBorders?: boolean; +}) => ( + <> + -
+ {onClose && } + ); 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..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 TrackOccupancyManchetteProps } from './types'; +import type { Track } from './types'; -const TrackOccupancyManchette = ({ tracks }: TrackOccupancyManchetteProps) => ( -
+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/TrackOccupancyStandalone.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx new file mode 100644 index 00000000000..c2e72b36146 --- /dev/null +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx @@ -0,0 +1,128 @@ +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, OccupancyZonePickingElement, Track } from './types'; +import { Manchette, useManchetteWithSpaceTimeChart } from '../../manchette'; +import { DEFAULT_THEME, SpaceTimeChart } from '../../spaceTimeChart'; +import { HOUR } from '../../spaceTimeChart/lib/consts'; + +const TrackOccupancyStandalone = ({ + tracks, + occupancyZones, + selectedTrainId, + onSelectedTrainIdChange, + height = TRACK_HEIGHT_CONTAINER * tracks.length + DEFAULT_THEME.timeCaptionsSize, +}: { + tracks: Track[]; + occupancyZones: OccupancyZone[]; + selectedTrainId?: string; + onSelectedTrainIdChange?: (selectedTrainId?: string) => void; + 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 + DEFAULT_THEME.timeCaptionsSize + ), + 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: true, + enableTimePan: true, + enableSpacePan: true, + enableTimeZoom: false, + }, + }); + + return ( +
+
+ {/* TODO: Bind actions? */} + +
+
+
+ +
+ { + if ( + hoveredItem?.layer === 'overlay' && + hoveredItem.element.type === 'occupancyZone' + ) { + const newId = (hoveredItem.element as OccupancyZonePickingElement).trainId; + onSelectedTrainIdChange(newId === selectedTrainId ? undefined : newId); + } else { + onSelectedTrainIdChange(undefined); + } + }) + } + /> +
+
+
+
+ ); +}; + +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..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 @@ -1,41 +1,24 @@ import { drawOccupancyZonesTexts } from './drawOccupancyZonesTexts'; -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; 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 +29,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,93 +37,68 @@ 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 = { - ctx: CanvasRenderingContext2D; - remainingTrainsNb: number; - xPosition: number; -}; - -const drawRemainingTrainsBox = ({ ctx, remainingTrainsNb, xPosition }: DrawRemainingTrainsBox) => { - const textY = 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 = ({ - 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; +export const drawOccupationZone = ( + ctx: CanvasRenderingContext2D, + stcContext: SpaceTimeChartContextType, + { + zone, + yOffset, + position, + isSelected, + }: { + zone: OccupancyZone; + yOffset: number; + position: number; + isSelected?: boolean; } +) => { + const isThroughTrain = zone.arrivalTime === zone.departureTime; ctx.fillStyle = zone.color; ctx.strokeStyle = WHITE_100; @@ -151,17 +106,23 @@ const drawOccupationZone = ({ ctx.lineCap = 'round'; ctx.font = '400 10px IBM Plex Mono'; - if (selectedTrainId === zone.id) { + const { getTimePixel, getSpacePixel } = stcContext; + 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 (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; - ctx.save(); ctx.fillStyle = SELECTION_20; ctx.beginPath(); ctx.roundRect( arrivalTimePixel - originTextLength - extraWidth, - yPosition - BACKGROUND_HEIGHT / 2, + y - BACKGROUND_HEIGHT / 2, departureTimePixel - arrivalTimePixel + originTextLength + @@ -171,169 +132,45 @@ const drawOccupationZone = ({ SELECTED_TRAIN_ID_GRADIANT ); ctx.fill(); - ctx.restore(); } + ctx.fillStyle = zone.color; if (isThroughTrain) { - drawThroughTrain({ ctx, arrivalTimePixel }); + drawThroughTrain(ctx, arrivalTimePixel, y); } else { - drawDefaultZone({ ctx, arrivalTimePixel, departureTimePixel, yPosition }); + drawDefaultZone(ctx, { + x: arrivalTimePixel, + y, + width: departureTimePixel - arrivalTimePixel, + }); } + // 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]); + if (zone.arrivalDirection) { + ctx.beginPath(); + ctx.moveTo(arrivalTimePixel, y); + ctx.lineTo(arrivalTimePixel, zone.arrivalDirection === 'up' ? yStart : yEnd); + ctx.stroke(); + } + if (zone.departureDirection) { + ctx.beginPath(); + ctx.moveTo(departureTimePixel, y); + ctx.lineTo(departureTimePixel, zone.departureDirection === 'up' ? yStart : yEnd); + ctx.stroke(); + } + ctx.setLineDash([]); + + // Draw texts: drawOccupancyZonesTexts({ ctx, zone, arrivalTimePixel, departureTimePixel, - yPosition, isThroughTrain, - selectedTrainId: currentSelectedTrainId, - }); -}; - -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(); - - if (!tracks || !occupancyZones || occupancyZones.length === 0) return; - - const sortedOccupancyZones = occupancyZones.sort( - (a, b) => a.arrivalTime.getTime() - b.arrivalTime.getTime() - ); - - tracks.forEach((track, index) => { - const trackTranslate = index === 0 ? CANVAS_PADDING : TRACK_HEIGHT_CONTAINER; - ctx.translate(0, trackTranslate); - - const { x: xMousePosition, y: yMousePosition } = mousePosition; - - const filteredOccupancyZones = sortedOccupancyZones.filter((zone) => zone.trackId === track.id); - - let primaryArrivalTimePixel = 0; - let primaryDepartureTimePixel = 0; - let lastDepartureTimePixel = primaryDepartureTimePixel; - 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 arrivalTimePixel = getTimePixel(zone.arrivalTime.getTime()); - const departureTimePixel = getTimePixel(zone.departureTime.getTime()); - const isThroughTrain = arrivalTimePixel === departureTimePixel; - - // * 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 (arrivalTimePixel > lastDepartureTimePixel) { - // reset to initial value if the zone is not overlapping - yPosition = OCCUPANCY_ZONE_Y_START; - primaryArrivalTimePixel = arrivalTimePixel; - primaryDepartureTimePixel = departureTimePixel; - lastDepartureTimePixel = departureTimePixel; - yOffset = Y_OFFSET_INCREMENT; - zoneCounter = 1; - - drawOccupationZone({ - ctx, - zone, - tracks, - arrivalTimePixel, - departureTimePixel, - yPosition, - isThroughTrain, - selectedTrainId, - setSelectedTrainId, - index, - xMousePosition, - yMousePosition, - }); - - 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 (arrivalTimePixel >= primaryArrivalTimePixel) { - if (zoneCounter % 2 === 0) { - yPosition -= yOffset; - } else { - yPosition += yOffset; - } - } - - // update the last departure time if the current zone is longer - if (departureTimePixel >= lastDepartureTimePixel) - lastDepartureTimePixel = departureTimePixel; - - drawOccupationZone({ - ctx, - zone, - tracks, - arrivalTimePixel, - departureTimePixel, - yPosition, - isThroughTrain, - selectedTrainId, - setSelectedTrainId, - index, - xMousePosition, - yMousePosition, - }); - - zoneCounter++; - yOffset += Y_OFFSET_INCREMENT; - zoneIndex++; - - continue; - } - - const nextIndex = filteredOccupancyZones.findIndex( - (filteredZone, i) => - i > zoneIndex && - getTimePixel(filteredZone.arrivalTime.getTime()) >= lastDepartureTimePixel - ); - - const remainingTrainsNb = nextIndex - zoneIndex; - - const xPosition = - primaryArrivalTimePixel + - (lastDepartureTimePixel - primaryArrivalTimePixel) / 2 - - REMAINING_TRAINS_WIDTH / 2; - - drawRemainingTrainsBox({ ctx, remainingTrainsNb, xPosition }); - - zoneIndex += remainingTrainsNb; - } + yPosition: y, + isSelected, }); - 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..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.id ? 'transparent' : WHITE_100, + color: isSelected ? 'transparent' : WHITE_100, width: STROKE_WIDTH, }; // train name - if (selectedTrainId === zone.id) { + if (isSelected) { 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..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 @@ -1,90 +1,59 @@ import { drawTrack } from './drawTrack'; +import type { SpaceTimeChartContextType } from '../../../../spaceTimeChart'; +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'; -export function getTimeToPixel( - timeOrigin: number, - pixelOffset: number, - timeScale: number -): (time: number) => number { - return (time: number) => pixelOffset + (time - timeOrigin) / timeScale; -} +const { HOUR_BACKGROUND_1, HOUR_BACKGROUND_2 } = COLORS; -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); +export const drawTracks = ( + ctx: CanvasRenderingContext2D, + stcContext: SpaceTimeChartContextType, + { + position, + tracks, + drawBorders, + topPadding = 0, + }: { + position: number; + tracks: Track[]; + drawBorders: boolean; + topPadding: number; } -}; - -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 { + width, + getSpacePixel, + getTime, + getTimePixel, + timeScale, + theme: { breakpoints, timeRanges }, + } = stcContext; + const yStart = getSpacePixel(position); + const yEnd = getSpacePixel(position, true); + const flatStepHeight = 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, timeStart, timeEnd, labelLevels); - 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; - } + // Draw backgrounds: + 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, flatStepHeight); + hours++; } + // Draw actual tracks: + ctx.save(); + ctx.translate(0, yStart + topPadding); tracks?.forEach((_, index) => { const trackTranslate = index === 0 ? CANVAS_PADDING : TRACK_HEIGHT_CONTAINER; ctx.translate(0, trackTranslate); @@ -96,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/OccupancyZonesLayer.tsx b/front/ui/ui-charts/src/trackOccupancyDiagram/components/layers/OccupancyZonesLayer.tsx index fabc0ada057..89815cd3993 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,220 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; -import { type LayerType, type DrawingFunction } from '../../../spaceTimeChart/lib/types'; -import { drawOccupancyZones } from '../helpers/drawElements/drawOccupancyZones'; +import { sortBy } from 'lodash'; -const OccupancyZonesLayer = ({ +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'; + +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; + +const OccupancyZonesLayer = ({ + tracks, + occupancyZones, + position, + topPadding, 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; + topPadding: number; + selectedTrainId?: string; }) => { + const instructionsToDraw = 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 lastDepartureTime = 0; + 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; + 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: (arrivalTime + 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, { getTimePixel, tracks, occupancyZones, trackOccupancyWidth, trackOccupancyHeight }) => { - if (trackOccupancyHeight && trackOccupancyWidth) - drawOccupancyZones({ - ctx, - width: trackOccupancyWidth, - height: trackOccupancyHeight, - tracks, - occupancyZones, - getTimePixel, - selectedTrainId, - setSelectedTrainId, - mousePosition, - }); + (ctx, stcContext) => { + instructionsToDraw.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, instructionsToDraw] + ); + + const pickingFunction = useCallback( + (imageData, { registerPickingElement, getTimePixel, getSpacePixel }, scalingRatio) => { + const flatStepOffsetY = getSpacePixel(position); + + instructionsToDraw.forEach((instruction) => { + if (instruction.type === 'occupancyZone') { + const x = getTimePixel(instruction.zone.arrivalTime); + const y = instruction.offsetY + flatStepOffsetY; + 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 + ); + } + }); }, - [mousePosition, selectedTrainId, setSelectedTrainId] + [position, instructionsToDraw] ); - useDraw('paths', drawingFunction); + usePicking('overlay', pickingFunction); + 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..00fe4204086 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,28 @@ 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, + topPadding, + drawBorders, +}: { + tracks: Track[]; + position: number; + topPadding: number; + drawBorders: boolean; +}) => { 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, topPadding, tracks, drawBorders }); }, - [] + [drawBorders, position, topPadding, 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..00569f4dfc2 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,19 @@ 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 }; + arrivalTime: number; + departureTime: number; }; -export type TrackOccupancyManchetteProps = { - tracks: Track[]; -}; +export type OccupancyZonePickingElement = { type: 'occupancyZone'; trainId: string }; 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..51bb2ecfd1e 100755 --- a/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css +++ b/front/ui/ui-charts/src/trackOccupancyDiagram/styles/main.css @@ -2,28 +2,50 @@ @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; -#track-occupancy-manchette, -.canvas-container { +.track-occupancy-manchette { height: 100%; width: 100%; 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; } -} -#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); + @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 { @@ -71,3 +93,51 @@ 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; + + .track-occupancy-manchette::before, + .track-occupancy-manchette::after { + display: none; + } + + .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; + flex-shrink: 0; + height: fit-content; + } + .manchette-actions { + position: absolute; + bottom: 0; + width: 200px; + + * { + display: none; + } + } +}