|
| 1 | +import React, { useContext, useMemo, useRef } from 'react'; |
| 2 | + |
| 3 | +import { |
| 4 | + type ChartPath, |
| 5 | + type Waypoint, |
| 6 | + HourlyTimetableMarkers, |
| 7 | + Manchette, |
| 8 | + PathLayer, |
| 9 | + SpaceTimeChart, |
| 10 | + SpaceTimeChartContext, |
| 11 | + useManchetteWithSpaceTimeChart, |
| 12 | +} from '@osrd-project/ui-charts'; |
| 13 | +import type { Meta, StoryObj } from '@storybook/react-vite'; |
| 14 | + |
| 15 | +import '@osrd-project/ui-core/dist/theme.css'; |
| 16 | +import '@osrd-project/ui-charts/dist/theme.css'; |
| 17 | + |
| 18 | +import { HOUR, MINUTE } from '../common/const'; |
| 19 | +import { SAMPLE_CHART_PATHS, SAMPLE_WAYPOINTS } from './assets/sampleData'; |
| 20 | + |
| 21 | +import './hourly-timetable.scss'; |
| 22 | + |
| 23 | +const DEFAULT_HEIGHT = 561; |
| 24 | +const SELECTED_TRAIN_NAME = 'Mission name'; |
| 25 | + |
| 26 | +const formatDuration = (ms: number) => { |
| 27 | + const totalMinutes = Math.round(ms / MINUTE); |
| 28 | + return `${Math.floor(totalMinutes / 60)}h ${(totalMinutes % 60).toString().padStart(2, '0')}min`; |
| 29 | +}; |
| 30 | + |
| 31 | +type SelectedPacedTrainInfoBarsProps = { |
| 32 | + duration: number; |
| 33 | + origin?: number; |
| 34 | + name: string; |
| 35 | + timeWindowLabel: string; |
| 36 | +}; |
| 37 | + |
| 38 | +const SelectedPacedTrainInfoBars = ({ |
| 39 | + duration, |
| 40 | + origin = 0, |
| 41 | + name, |
| 42 | + timeWindowLabel, |
| 43 | +}: SelectedPacedTrainInfoBarsProps) => { |
| 44 | + const { timeScale, timeOrigin, timePixelOffset, getTimePixel, width, swapAxis, captionSize } = |
| 45 | + useContext(SpaceTimeChartContext); |
| 46 | + |
| 47 | + const bars = useMemo(() => { |
| 48 | + if (!duration || duration <= 0 || swapAxis) return []; |
| 49 | + const minT = timeOrigin - timeScale * timePixelOffset; |
| 50 | + const maxT = minT + timeScale * width; |
| 51 | + const firstIndex = Math.floor((minT - origin) / duration); |
| 52 | + const lastIndex = Math.ceil((maxT - origin) / duration); |
| 53 | + const result: { key: number; left: number; width: number }[] = []; |
| 54 | + for (let n = firstIndex; n < lastIndex; n++) { |
| 55 | + const left = getTimePixel(origin + n * duration); |
| 56 | + const w = getTimePixel(origin + (n + 1) * duration) - left; |
| 57 | + if (w > 0) result.push({ key: n, left, width: w }); |
| 58 | + } |
| 59 | + return result; |
| 60 | + }, [duration, origin, timeScale, timeOrigin, timePixelOffset, getTimePixel, width, swapAxis]); |
| 61 | + |
| 62 | + return ( |
| 63 | + <> |
| 64 | + {bars.map(({ key, left, width: w }) => ( |
| 65 | + <div |
| 66 | + key={key} |
| 67 | + className="selected-paced-train-info" |
| 68 | + style={{ left, width: w, bottom: captionSize + 17 }} |
| 69 | + > |
| 70 | + <span>{name}</span> |
| 71 | + <span>|</span> |
| 72 | + <span>{timeWindowLabel}</span> |
| 73 | + </div> |
| 74 | + ))} |
| 75 | + </> |
| 76 | + ); |
| 77 | +}; |
| 78 | + |
| 79 | +type WrapperProps = { |
| 80 | + waypoints: Waypoint[]; |
| 81 | + paths: ChartPath[]; |
| 82 | + selectedTrain: number; |
| 83 | + patternDurationHours: number; |
| 84 | +}; |
| 85 | + |
| 86 | +const Wrapper = ({ waypoints, paths, selectedTrain, patternDurationHours }: WrapperProps) => { |
| 87 | + const manchetteWithSpaceTimeChartRef = useRef<HTMLDivElement>(null); |
| 88 | + const patternDuration = patternDurationHours * HOUR; |
| 89 | + const origin = Math.min(...paths.map((p) => +p.points[0]?.time)); |
| 90 | + |
| 91 | + const { manchetteProps, spaceTimeChartProps, handleScroll } = useManchetteWithSpaceTimeChart({ |
| 92 | + waypoints, |
| 93 | + manchetteWithSpaceTimeChartRef, |
| 94 | + defaultTimeOrigin: origin, |
| 95 | + }); |
| 96 | + |
| 97 | + const selectedPath = paths[selectedTrain]?.id; |
| 98 | + |
| 99 | + return ( |
| 100 | + <div className="ui-manchette-space-time-chart-wrapper"> |
| 101 | + <div |
| 102 | + className="header bg-ambientB-5 w-full border-b border-grey-30" |
| 103 | + style={{ height: '40px' }} |
| 104 | + /> |
| 105 | + <div |
| 106 | + ref={manchetteWithSpaceTimeChartRef} |
| 107 | + className="manchette flex" |
| 108 | + style={{ height: `${DEFAULT_HEIGHT}px`, position: 'relative' }} |
| 109 | + onScroll={handleScroll} |
| 110 | + > |
| 111 | + <Manchette {...manchetteProps} /> |
| 112 | + <div className="space-time-chart-container w-full sticky"> |
| 113 | + <SpaceTimeChart className="inset-0 absolute h-full" {...spaceTimeChartProps}> |
| 114 | + {paths.map((path) => ( |
| 115 | + <PathLayer |
| 116 | + key={path.id} |
| 117 | + path={path} |
| 118 | + color={path.color} |
| 119 | + level={path.id === selectedPath ? 1 : 2} |
| 120 | + /> |
| 121 | + ))} |
| 122 | + <HourlyTimetableMarkers duration={patternDuration} origin={origin} /> |
| 123 | + <SelectedPacedTrainInfoBars |
| 124 | + duration={patternDuration} |
| 125 | + origin={origin} |
| 126 | + name={SELECTED_TRAIN_NAME} |
| 127 | + timeWindowLabel={formatDuration(patternDuration)} |
| 128 | + /> |
| 129 | + </SpaceTimeChart> |
| 130 | + </div> |
| 131 | + </div> |
| 132 | + </div> |
| 133 | + ); |
| 134 | +}; |
| 135 | + |
| 136 | +const meta: Meta<typeof Wrapper> = { |
| 137 | + title: 'Manchette with SpaceTimeChart/Hourly timetable', |
| 138 | + component: Wrapper, |
| 139 | + argTypes: { |
| 140 | + patternDurationHours: { |
| 141 | + name: 'Hourly pattern duration (h)', |
| 142 | + control: { type: 'range', min: 1, max: 6, step: 0.5 }, |
| 143 | + }, |
| 144 | + selectedTrain: { |
| 145 | + name: 'Selected train index', |
| 146 | + control: { type: 'number', min: 0, max: SAMPLE_CHART_PATHS.length - 1 }, |
| 147 | + }, |
| 148 | + }, |
| 149 | +}; |
| 150 | + |
| 151 | +export default meta; |
| 152 | + |
| 153 | +export const Default: StoryObj<typeof Wrapper> = { |
| 154 | + name: 'Hourly pattern selected', |
| 155 | + args: { |
| 156 | + waypoints: SAMPLE_WAYPOINTS, |
| 157 | + paths: SAMPLE_CHART_PATHS, |
| 158 | + selectedTrain: 1, |
| 159 | + patternDurationHours: 2, |
| 160 | + }, |
| 161 | +}; |
0 commit comments