Skip to content
This repository was archived by the owner on Aug 5, 2025. It is now read-only.

Commit c14670f

Browse files
committed
ui-charts: restores clicking trains in the gov
Details: - Implements back clicks detection in the TrackOccupancy diagram, using the picking framework from the SpaceTimeDiagram - Adds an example showing how it works in the trackOccupancyDiagram/rendering story Signed-off-by: Alexis Jacomy <[email protected]>
1 parent c718b52 commit c14670f

File tree

7 files changed

+265
-176
lines changed

7 files changed

+265
-176
lines changed

storybook/stories/ui-charts/trackOccupancyDiagram/rendering.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const TrackOccupancyDiagramStory = ({
3030
tracks={TRACKS}
3131
occupancyZones={OCCUPANCY_ZONES}
3232
selectedTrainId={selectedTrainId}
33+
onSelectedTrainIdChange={setSelectedTrainId}
3334
height={autoHeight ? undefined : 500}
3435
/>
3536
</div>

ui-charts/src/spaceTimeChart/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export type PointToData = (point: Point) => DataPoint;
9393
export type DataToPoint = (data: DataPoint) => Point;
9494

9595
// CANVAS SPECIFIC TYPES:
96-
export const PICKING_LAYERS = ['paths'] as const;
96+
export const PICKING_LAYERS = ['paths', 'overlay'] as const;
9797
export type PickingLayerType = (typeof PICKING_LAYERS)[number];
9898
export const LAYERS = ['background', 'graduations', 'paths', 'overlay', 'captions'] as const;
9999
export type LayerType = (typeof LAYERS)[number];

ui-charts/src/trackOccupancyDiagram/components/TrackOccupancyStandalone.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { KebabHorizontal } from '@osrd-project/ui-icons';
55
import { TRACK_HEIGHT_CONTAINER } from './consts';
66
import TrackOccupancyCanvas from './TrackOccupancyCanvas';
77
import TrackOccupancyManchette from './TrackOccupancyManchette';
8-
import type { OccupancyZone, Track } from './types';
8+
import type { OccupancyZone, OccupancyZonePickingElement, Track } from './types';
99
import { Manchette, useManchetteWithSpaceTimeChart } from '../../manchette';
1010
import { SpaceTimeChart } from '../../spaceTimeChart';
1111
import { HOUR } from '../../spaceTimeChart/lib/consts';
@@ -14,11 +14,13 @@ const TrackOccupancyStandalone = ({
1414
tracks,
1515
occupancyZones,
1616
selectedTrainId,
17+
onSelectedTrainIdChange,
1718
height = TRACK_HEIGHT_CONTAINER * tracks.length,
1819
}: {
1920
tracks: Track[];
2021
occupancyZones: OccupancyZone[];
2122
selectedTrainId?: string;
23+
onSelectedTrainIdChange?: (selectedTrainId?: string) => void;
2224
height?: number;
2325
}) => {
2426
const manchetteWithSpaceTimeChartRef = useRef<HTMLDivElement>(null);
@@ -94,7 +96,24 @@ const TrackOccupancyStandalone = ({
9496
>
9597
<Manchette {...manchetteProps} />
9698
<div className="space-time-chart-container w-full sticky" ref={spaceTimeChartRef}>
97-
<SpaceTimeChart className="inset-0 absolute h-full" {...spaceTimeChartProps} />
99+
<SpaceTimeChart
100+
className="inset-0 absolute h-full"
101+
{...spaceTimeChartProps}
102+
onClick={
103+
onSelectedTrainIdChange &&
104+
(({ hoveredItem }) => {
105+
if (
106+
hoveredItem?.layer === 'overlay' &&
107+
hoveredItem.element.type === 'occupancyZone'
108+
) {
109+
const newId = (hoveredItem.element as OccupancyZonePickingElement).trainId;
110+
onSelectedTrainIdChange(newId === selectedTrainId ? undefined : newId);
111+
} else {
112+
onSelectedTrainIdChange(undefined);
113+
}
114+
})
115+
}
116+
/>
98117
</div>
99118
</div>
100119
</div>
Lines changed: 39 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
11
import { drawOccupancyZonesTexts } from './drawOccupancyZonesTexts';
2-
import type { SpaceTimeChartContextType } from '../../../../spaceTimeChart';
3-
import {
4-
TRACK_HEIGHT_CONTAINER,
5-
CANVAS_PADDING,
6-
OCCUPANCY_ZONE_Y_START,
7-
OCCUPANCY_ZONE_HEIGHT,
8-
FONTS,
9-
COLORS,
10-
} from '../../consts';
11-
import type { OccupancyZone, Track } from '../../types';
2+
import { getCrispLineCoordinate, type SpaceTimeChartContextType } from '../../../../spaceTimeChart';
3+
import { OCCUPANCY_ZONE_Y_START, OCCUPANCY_ZONE_HEIGHT, FONTS, COLORS } from '../../consts';
4+
import type { OccupancyZone } from '../../types';
125

136
const { SANS } = FONTS;
147
const { REMAINING_TRAINS_BACKGROUND, WHITE_100, SELECTION_20 } = COLORS;
158
const REMAINING_TRAINS_WIDTH = 70;
169
const REMAINING_TRAINS_HEIGHT = 24;
1710
const REMAINING_TEXT_OFFSET = 12;
18-
const Y_OFFSET_INCREMENT = 4;
19-
const MAX_ZONES = 9;
2011
const X_BACKGROUND_PADDING = 4;
2112
const X_TROUGHTRAIN_BACKGROUND_PADDING = 8;
2213
const BACKGROUND_HEIGHT = 40;
@@ -61,48 +52,50 @@ const drawThroughTrain = (ctx: CanvasRenderingContext2D, x: number, y: number) =
6152
ctx.stroke();
6253
};
6354

64-
const drawRemainingTrainsBox = ({
65-
ctx,
66-
remainingTrainsNb,
67-
xPosition,
68-
yPosition,
69-
}: {
70-
ctx: CanvasRenderingContext2D;
71-
remainingTrainsNb: number;
72-
xPosition: number;
73-
yPosition: number;
74-
}) => {
75-
const textY = yPosition + OCCUPANCY_ZONE_Y_START - REMAINING_TEXT_OFFSET;
55+
export const drawRemainingTrainsBox = (
56+
ctx: CanvasRenderingContext2D,
57+
{ getTimePixel, getSpacePixel }: SpaceTimeChartContextType,
58+
{
59+
time,
60+
position,
61+
yOffset,
62+
remainingTrainsNb,
63+
}: {
64+
time: number;
65+
position: number;
66+
yOffset: number;
67+
remainingTrainsNb: number;
68+
}
69+
) => {
70+
const x = getTimePixel(time);
71+
const y = getSpacePixel(position) + yOffset;
72+
const textY = y + OCCUPANCY_ZONE_Y_START - REMAINING_TEXT_OFFSET;
7673

7774
ctx.fillStyle = REMAINING_TRAINS_BACKGROUND;
7875
ctx.beginPath();
79-
ctx.rect(xPosition, textY, REMAINING_TRAINS_WIDTH, REMAINING_TRAINS_HEIGHT);
76+
ctx.rect(x - REMAINING_TRAINS_WIDTH / 2, textY, REMAINING_TRAINS_WIDTH, REMAINING_TRAINS_HEIGHT);
8077
ctx.fill();
8178
ctx.stroke();
8279
ctx.fillStyle = WHITE_100;
8380
ctx.font = SANS;
8481
ctx.textAlign = 'center';
8582
ctx.textBaseline = 'middle';
86-
ctx.fillText(
87-
`+${remainingTrainsNb} trains`,
88-
xPosition + REMAINING_TRAINS_WIDTH / 2,
89-
textY + REMAINING_TRAINS_HEIGHT / 2
90-
);
83+
ctx.fillText(`+${remainingTrainsNb} trains`, x, textY + REMAINING_TRAINS_HEIGHT / 2);
9184
};
9285

93-
const drawOccupationZone = (
86+
export const drawOccupationZone = (
9487
ctx: CanvasRenderingContext2D,
9588
stcContext: SpaceTimeChartContextType,
9689
{
9790
zone,
91+
yOffset,
9892
position,
99-
yZone,
100-
selectedTrainId,
93+
isSelected,
10194
}: {
10295
zone: OccupancyZone;
96+
yOffset: number;
10397
position: number;
104-
yZone: number;
105-
selectedTrainId?: string;
98+
isSelected?: boolean;
10699
}
107100
) => {
108101
const isThroughTrain = zone.arrivalTime === zone.departureTime;
@@ -114,12 +107,13 @@ const drawOccupationZone = (
114107
ctx.font = '400 10px IBM Plex Mono';
115108

116109
const { getTimePixel, getSpacePixel } = stcContext;
117-
const yStart = getSpacePixel(position);
110+
const yStart = getCrispLineCoordinate(getSpacePixel(position), BACKGROUND_HEIGHT);
111+
const y = yStart + yOffset;
118112
const yEnd = getSpacePixel(position, true);
119113
const arrivalTimePixel = getTimePixel(zone.arrivalTime);
120114
const departureTimePixel = getTimePixel(zone.departureTime);
121115

122-
if (selectedTrainId === zone.trainId) {
116+
if (isSelected) {
123117
const extraWidth = isThroughTrain ? X_TROUGHTRAIN_BACKGROUND_PADDING : X_BACKGROUND_PADDING;
124118
const originTextLength = ctx.measureText(zone.originStation || '--').width;
125119
const destinationTextLength = ctx.measureText(zone.destinationStation || '--').width;
@@ -128,7 +122,7 @@ const drawOccupationZone = (
128122
ctx.beginPath();
129123
ctx.roundRect(
130124
arrivalTimePixel - originTextLength - extraWidth,
131-
yZone - BACKGROUND_HEIGHT / 2,
125+
y - BACKGROUND_HEIGHT / 2,
132126
departureTimePixel -
133127
arrivalTimePixel +
134128
originTextLength +
@@ -140,12 +134,13 @@ const drawOccupationZone = (
140134
ctx.fill();
141135
}
142136

137+
ctx.fillStyle = zone.color;
143138
if (isThroughTrain) {
144-
drawThroughTrain(ctx, arrivalTimePixel, yZone);
139+
drawThroughTrain(ctx, arrivalTimePixel, y);
145140
} else {
146141
drawDefaultZone(ctx, {
147142
x: arrivalTimePixel,
148-
y: yZone,
143+
y,
149144
width: departureTimePixel - arrivalTimePixel,
150145
});
151146
}
@@ -156,13 +151,13 @@ const drawOccupationZone = (
156151
ctx.setLineDash([1, 4]);
157152
if (zone.arrivalDirection) {
158153
ctx.beginPath();
159-
ctx.moveTo(arrivalTimePixel, yZone);
154+
ctx.moveTo(arrivalTimePixel, y);
160155
ctx.lineTo(arrivalTimePixel, zone.arrivalDirection === 'up' ? yStart : yEnd);
161156
ctx.stroke();
162157
}
163158
if (zone.departureDirection) {
164159
ctx.beginPath();
165-
ctx.moveTo(departureTimePixel, yZone);
160+
ctx.moveTo(departureTimePixel, y);
166161
ctx.lineTo(departureTimePixel, zone.departureDirection === 'up' ? yStart : yEnd);
167162
ctx.stroke();
168163
}
@@ -175,121 +170,7 @@ const drawOccupationZone = (
175170
arrivalTimePixel,
176171
departureTimePixel,
177172
isThroughTrain,
178-
selectedTrainId,
179-
yPosition: yZone,
180-
});
181-
};
182-
183-
export const drawOccupancyZones = (
184-
ctx: CanvasRenderingContext2D,
185-
stcContext: SpaceTimeChartContextType,
186-
{
187-
occupancyZones,
188-
tracks,
189-
position,
190-
selectedTrainId,
191-
topPadding = 0,
192-
}: {
193-
occupancyZones: OccupancyZone[];
194-
tracks: Track[];
195-
position: number;
196-
selectedTrainId?: string;
197-
topPadding?: number;
198-
}
199-
) => {
200-
if (!tracks || !occupancyZones || occupancyZones.length === 0) return;
201-
202-
const { getTimePixel, getSpacePixel } = stcContext;
203-
const baseY = getSpacePixel(position) + topPadding;
204-
205-
const sortedOccupancyZones = occupancyZones.sort((a, b) => a.arrivalTime - b.arrivalTime);
206-
207-
tracks.forEach((track, index) => {
208-
const trackY = baseY + CANVAS_PADDING + index * TRACK_HEIGHT_CONTAINER;
209-
210-
const filteredOccupancyZones = sortedOccupancyZones.filter((zone) => zone.trackId === track.id);
211-
212-
let primaryArrivalTime = 0;
213-
let primaryDepartureTime = 0;
214-
let lastDepartureTime = primaryDepartureTime;
215-
let yPosition = OCCUPANCY_ZONE_Y_START;
216-
let yOffset = Y_OFFSET_INCREMENT;
217-
let zoneCounter = 0;
218-
let zoneIndex = 0;
219-
220-
while (zoneIndex < filteredOccupancyZones.length) {
221-
const zone = filteredOccupancyZones[zoneIndex];
222-
const { arrivalTime, departureTime } = zone;
223-
224-
// * if the zone is not overlapping with any previous one, draw it in the center of the track
225-
// * and reset the primary values
226-
// *
227-
// * if the zone is overlapping with the previous one, draw it below or above the previous one
228-
// * depending on the overlapping counter
229-
// *
230-
// * if the zone is overlapping with the previous one and the counter is higher than the max zones
231-
// * draw the remaining trains box
232-
// *
233-
if (arrivalTime > lastDepartureTime) {
234-
// reset to initial value if the zone is not overlapping
235-
yPosition = OCCUPANCY_ZONE_Y_START;
236-
primaryArrivalTime = arrivalTime;
237-
primaryDepartureTime = departureTime;
238-
lastDepartureTime = departureTime;
239-
yOffset = Y_OFFSET_INCREMENT;
240-
zoneCounter = 1;
241-
242-
drawOccupationZone(ctx, stcContext, {
243-
zone,
244-
position,
245-
selectedTrainId,
246-
yZone: trackY + yPosition,
247-
});
248-
249-
zoneIndex++;
250-
251-
continue;
252-
}
253-
254-
if (zoneCounter < MAX_ZONES) {
255-
// if so and it's an even index, move it to the bottom, if it's an odd index, move it to the top
256-
if (arrivalTime >= primaryArrivalTime) {
257-
if (zoneCounter % 2 === 0) {
258-
yPosition -= yOffset;
259-
} else {
260-
yPosition += yOffset;
261-
}
262-
}
263-
264-
// update the last departure time if the current zone is longer
265-
if (departureTime >= lastDepartureTime) lastDepartureTime = departureTime;
266-
267-
drawOccupationZone(ctx, stcContext, {
268-
zone,
269-
position,
270-
yZone: trackY + yPosition,
271-
selectedTrainId,
272-
});
273-
274-
zoneCounter++;
275-
yOffset += Y_OFFSET_INCREMENT;
276-
zoneIndex++;
277-
278-
continue;
279-
}
280-
281-
const nextIndex = filteredOccupancyZones.findIndex(
282-
(filteredZone, i) => i > zoneIndex && filteredZone.arrivalTime >= lastDepartureTime
283-
);
284-
285-
const remainingTrainsNb = nextIndex - zoneIndex;
286-
287-
const xPosition =
288-
getTimePixel((primaryArrivalTime + lastDepartureTime) / 2) - REMAINING_TRAINS_WIDTH / 2;
289-
290-
drawRemainingTrainsBox({ ctx, remainingTrainsNb, xPosition, yPosition: trackY });
291-
292-
zoneIndex += remainingTrainsNb;
293-
}
173+
yPosition: y,
174+
isSelected,
294175
});
295176
};

ui-charts/src/trackOccupancyDiagram/components/helpers/drawElements/drawOccupancyZonesTexts.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ export const drawOccupancyZonesTexts = ({
2828
departureTimePixel,
2929
yPosition,
3030
isThroughTrain,
31-
selectedTrainId,
31+
isSelected,
3232
}: {
3333
ctx: CanvasRenderingContext2D;
3434
zone: OccupancyZone;
3535
arrivalTimePixel: number;
3636
departureTimePixel: number;
3737
yPosition: number;
3838
isThroughTrain: boolean;
39-
selectedTrainId?: string;
39+
isSelected?: boolean;
4040
}) => {
4141
const zoneOccupancyLength = departureTimePixel - arrivalTimePixel - STROKE_WIDTH;
4242

@@ -66,12 +66,12 @@ export const drawOccupancyZonesTexts = ({
6666
const xDeparturePosition = isBelowBreakpoint('small') ? 'left' : 'center';
6767

6868
const textStroke = {
69-
color: selectedTrainId === zone.trainId ? 'transparent' : WHITE_100,
69+
color: isSelected ? 'transparent' : WHITE_100,
7070
width: STROKE_WIDTH,
7171
};
7272

7373
// train name
74-
if (selectedTrainId === zone.trainId) {
74+
if (isSelected) {
7575
const { xSelectedTrainNameBackground, ySelectedTrainNameBackground } = isBelowBreakpoint(
7676
'medium'
7777
)

0 commit comments

Comments
 (0)