Skip to content

Commit

Permalink
ui-spacetimechart: implements chart splitting
Browse files Browse the repository at this point in the history
This commit fixes osrd-project/osrd-confidential#870.

Details:
- Updates getSpaceBreakpoints to no more deduplicate repeted values
  (those repeted values are now used to detect flat steps)
- Updates getNormalizedScaleAtPosition to accept an option to return
  last matching scale instead of first (important now that a single
  position can match multiple pixel positions)
- Similarly updates getSpaceToPixel to return a function that accepts a
  new fromEnd option
- Fixes getPathSegments in PathLayer to properly handle flat steps
- Renders space graduations twice on flat steps (one before, one after)
- Renders path pauses twice on flat steps as well (one before, one after)
- Adds a new story to showcase how to split the space time chart

Signed-off-by: Alexis Jacomy <[email protected]>
  • Loading branch information
jacomyal committed Feb 27, 2025
1 parent 3da588e commit 2f5e675
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 35 deletions.
10 changes: 9 additions & 1 deletion ui-spacetimechart/src/__tests__/scales.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('getSpaceBreakpoints', () => {
{ to: 90, size: 50 },
{ to: 140, size: 50 },
]);
expect(getSpaceBreakpoints(5, 135, tree)).toEqual([10, 60, 70, 90]);
expect(getSpaceBreakpoints(5, 135, tree)).toEqual([10, 60, 60, 70, 90]);
});
});

Expand Down Expand Up @@ -171,10 +171,18 @@ describe('getNormalizedScaleAtPosition', () => {
{ to: 90, size: 50 },
{ to: 140, size: 50 },
]);

expect(pick(getNormalizedScaleAtPosition(60, tree) as NormalizedScale, 'from', 'to')).toEqual({
from: 10,
to: 60,
});

expect(
pick(getNormalizedScaleAtPosition(60, tree, true) as NormalizedScale, 'from', 'to')
).toEqual({
from: 60,
to: 70,
});
});

it('should return extremities when out of scope', () => {
Expand Down
42 changes: 29 additions & 13 deletions ui-spacetimechart/src/components/PathLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
getCrispLineCoordinate,
} from '../utils/canvas';
import { indexToColor, hexToRgb } from '../utils/colors';
import { getPathDirection } from '../utils/paths';
import { getPathDirection, getSpacePixels } from '../utils/paths';
import { getSpaceBreakpoints } from '../utils/scales';

const DEFAULT_PICKING_TOLERANCE = 5;
Expand Down Expand Up @@ -102,14 +102,27 @@ export const PathLayer = ({
} else {
const { position: prevPosition, time: prevTime } = a[i - 1];
const spaceBreakPoints = getSpaceBreakpoints(prevPosition, position, spaceScaleTree);
spaceBreakPoints.forEach((breakPosition) => {
let previousBreakPosition = -Infinity;
spaceBreakPoints.forEach((breakPosition, index) => {
const nextBreakPosition = spaceBreakPoints[index + 1] ?? Infinity;
const isBeforeFlatStep = previousBreakPosition === breakPosition;
const isAfterFlatStep = breakPosition === nextBreakPosition;

const readSpacePixelFromEnd = isBeforeFlatStep
? getPathDirection(path, i, true) === 'forward'
: isAfterFlatStep
? getPathDirection(path, i - 1) === 'backward'
: false;

const breakTime =
prevTime +
((breakPosition - prevPosition) / (position - prevPosition)) * (time - prevTime);

res.push({
[timeAxis]: getTimePixel(breakTime),
[spaceAxis]: getSpacePixel(breakPosition),
[spaceAxis]: getSpacePixel(breakPosition, readSpacePixelFromEnd),
} as Point);
previousBreakPosition = breakPosition;
});
res.push({
[timeAxis]: getTimePixel(time),
Expand Down Expand Up @@ -156,16 +169,19 @@ export const PathLayer = ({
if (i) {
const { position: prevPosition, time: prevTime } = a[i - 1];
if (prevPosition === position && stopPositions.has(position)) {
const spacePixel = getCrispLineCoordinate(getSpacePixel(position), ctx.lineWidth);
ctx.beginPath();
if (!swapAxis) {
ctx.moveTo(getTimePixel(prevTime), spacePixel);
ctx.lineTo(getTimePixel(time), spacePixel);
} else {
ctx.moveTo(spacePixel, getTimePixel(prevTime));
ctx.lineTo(spacePixel, getTimePixel(time));
}
ctx.stroke();
// 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);
ctx.beginPath();
if (!swapAxis) {
ctx.moveTo(getTimePixel(prevTime), spacePixel);
ctx.lineTo(getTimePixel(time), spacePixel);
} else {
ctx.moveTo(spacePixel, getTimePixel(prevTime));
ctx.lineTo(spacePixel, getTimePixel(time));
}
ctx.stroke();
});
}
}
});
Expand Down
24 changes: 14 additions & 10 deletions ui-spacetimechart/src/components/SpaceGraduations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback } from 'react';
import { useDraw } from '../hooks/useCanvas';
import { type DrawingFunction } from '../lib/types';
import { getCrispLineCoordinate } from '../utils/canvas';
import { getSpacePixels } from '../utils/paths';

const SpaceGraduations = () => {
const drawingFunction = useCallback<DrawingFunction>(
Expand Down Expand Up @@ -33,17 +34,20 @@ const SpaceGraduations = () => {
ctx.lineDashOffset = -timePixelOffset;
}

const spacePixel = getCrispLineCoordinate(getSpacePixel(point.position), ctx.lineWidth);
// Detect flat steps, and draw two graduations if any (one on each side of the step):
getSpacePixels(getSpacePixel, point.position).forEach((rawPixel) => {
const spacePixel = getCrispLineCoordinate(rawPixel, ctx.lineWidth);

ctx.beginPath();
if (!swapAxis) {
ctx.moveTo(0, spacePixel);
ctx.lineTo(axisSize, spacePixel);
} else {
ctx.moveTo(spacePixel, 0);
ctx.lineTo(spacePixel, axisSize);
}
ctx.stroke();
ctx.beginPath();
if (!swapAxis) {
ctx.moveTo(0, spacePixel);
ctx.lineTo(axisSize, spacePixel);
} else {
ctx.moveTo(spacePixel, 0);
ctx.lineTo(spacePixel, axisSize);
}
ctx.stroke();
});
});

ctx.setLineDash([]);
Expand Down
1 change: 1 addition & 0 deletions ui-spacetimechart/src/hooks/useCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export function useCanvas(
if (!canvases[layerId]) {
const canvas = document.createElement('CANVAS') as HTMLCanvasElement;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
canvas.classList.add(`layer-${type}-${layer}`);
canvas.style.position = 'absolute';
canvas.style.inset = '0';
dom.appendChild(canvas);
Expand Down
4 changes: 2 additions & 2 deletions ui-spacetimechart/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export type Direction = 'forward' | 'backward' | 'still';

// DATA TRANSLATION TYPES:
export type TimeToPixel = (time: number) => number;
export type SpaceToPixel = (position: number) => number;
export type SpaceToPixel = (position: number, fromEnd?: boolean) => number;
export type PixelToTime = (x: number) => number;
export type PixelToSpace = (y: number) => number;
export type PointToData = (point: Point) => DataPoint;
Expand All @@ -100,7 +100,7 @@ export type DataToPoint = (data: DataPoint) => Point;
// CANVAS SPECIFIC TYPES:
export const PICKING_LAYERS = ['paths'] as const;
export type PickingLayerType = (typeof PICKING_LAYERS)[number];
export const LAYERS = ['background', 'graduations', 'paths', 'captions', 'overlay'] as const;
export const LAYERS = ['background', 'graduations', 'paths', 'overlay', 'captions'] as const;
export type LayerType = (typeof LAYERS)[number];

// PICKING SPECIFIC TYPES:
Expand Down
2 changes: 2 additions & 0 deletions ui-spacetimechart/src/stories/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ const REVERSED_BACK_AND_FORTH_POINTS = [

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 & { color: string })[] = [
// Omnibuses:
...getPaths(
Expand Down
204 changes: 204 additions & 0 deletions ui-spacetimechart/src/stories/split.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import React, { useCallback, useMemo, useState } from 'react';

import '@osrd-project/ui-core/dist/theme.css';

import type { Meta } from '@storybook/react';
import { clamp, keyBy } from 'lodash';

import { OPERATIONAL_POINTS, PATHS } from './lib/paths';
import { PathLayer } from '../components/PathLayer';
import { SpaceTimeChart } from '../components/SpaceTimeChart';
import type { DrawingFunction, Point } from '../lib/types';
import { getDiff } from '../utils/vectors';
import { X_ZOOM_LEVEL, Y_ZOOM_LEVEL, zoom } from './lib/utils';
import { useDraw } from '../hooks/useCanvas';
import { AMBIANT_A10 } from '../lib/consts';

const COEFFICIENT = 300;

/**
* This component renders a colored area where the line only has one track:
*/
const FlatStep = ({ position }: { position: number }) => {
const drawMonoTrackSpace = useCallback<DrawingFunction>(
(ctx, { getSpacePixel, width, height, spaceAxis }) => {
const spaceSize = spaceAxis === 'x' ? width : height;
const timeSize = spaceAxis === 'x' ? height : width;
const fromPixel = clamp(getSpacePixel(position), 0, spaceSize);
const toPixel = clamp(getSpacePixel(position, true), 0, spaceSize);
const monoLineSize = toPixel - fromPixel;
if (!monoLineSize) return;

ctx.fillStyle = AMBIANT_A10;
if (spaceAxis === 'x') {
ctx.fillRect(fromPixel, 0, monoLineSize, timeSize);
} else {
ctx.fillRect(0, fromPixel, timeSize, monoLineSize);
}
},
[position]
);

useDraw('overlay', drawMonoTrackSpace);

return null;
};

type WrapperProps = {
splitPoints: string;
splitHeight: number;
scaleWithZoom: boolean;
swapAxis: boolean;
};

const SplitSpaceTimeChartWrapper = ({
splitPoints,
splitHeight,
scaleWithZoom,
swapAxis,
}: WrapperProps) => {
const [state, setState] = useState<{
xOffset: number;
yOffset: number;
xZoomLevel: number;
yZoomLevel: number;
panning: null | { initialOffset: Point };
}>({
xOffset: 0,
yOffset: 0,
xZoomLevel: X_ZOOM_LEVEL,
yZoomLevel: Y_ZOOM_LEVEL,
panning: null,
});

// For this story, we split the chart on "City C" and "City E:
const fullSplitPoints = useMemo(() => {
const operationalPointsDict = keyBy(OPERATIONAL_POINTS, 'id');
const splitPointsSet = new Set(splitPoints.split(','));
return 'ABCDEF'
.split('')
.filter((letter) => splitPointsSet.has(letter))
.map((letter) => ({
position: operationalPointsDict[`city-${letter.toLowerCase()}`].position,
label: operationalPointsDict[`city-${letter.toLowerCase()}`].label,
height: scaleWithZoom ? splitHeight * state.yZoomLevel : splitHeight,
}));
}, [scaleWithZoom, splitHeight, splitPoints, state.yZoomLevel]);

const spaceScales = useMemo(
() =>
fullSplitPoints
.flatMap(({ position, height }) => [
{
to: position,
coefficient: COEFFICIENT / state.yZoomLevel,
},
{
to: position,
size: height,
},
])
.concat({
to: OPERATIONAL_POINTS.at(-1)!.position,
coefficient: COEFFICIENT / state.yZoomLevel,
}),
[fullSplitPoints, state.yZoomLevel]
);

return (
<div className="absolute inset-0">
<SpaceTimeChart
className="h-full"
spaceOrigin={0}
swapAxis={swapAxis}
xOffset={state.xOffset}
yOffset={state.yOffset}
timeOrigin={+new Date('2024/04/02')}
operationalPoints={OPERATIONAL_POINTS}
timeScale={100000 / state.xZoomLevel}
spaceScales={spaceScales}
onPan={({ initialPosition, position, isPanning }) => {
const { panning } = state;
const diff = getDiff(initialPosition, position);

// Stop panning:
if (!isPanning) {
setState((prev) => ({
...prev,
panning: null,
}));
}
// Start panning stage
else if (!panning) {
setState((prev) => ({
...prev,
panning: {
initialOffset: {
x: prev.xOffset,
y: prev.yOffset,
},
},
}));
}
// Keep panning stage:
else {
const xOffset = panning.initialOffset.x + diff.x;
const yOffset = panning.initialOffset.y + diff.y;

setState((prev) => ({
...prev,
xOffset,
yOffset,
}));
}
}}
onZoom={(payload) => {
setState((prev) => ({
...prev,
...zoom(state, payload),
}));
}}
>
{PATHS.map((path) => (
<PathLayer key={path.id} path={path} color={path.color} />
))}
{fullSplitPoints.map(({ position }, i) => (
<FlatStep key={i} position={position} />
))}
</SpaceTimeChart>
</div>
);
};

export default {
title: 'SpaceTimeChart/Split',
component: SplitSpaceTimeChartWrapper,
argTypes: {
splitPoints: {
name: 'Operational points to split on (pick in A, B, C, D, E and F, separate with commas)',
control: { type: 'text' },
},
splitHeight: {
name: 'Split steps height (in pixels)',
control: { type: 'number' },
},
scaleWithZoom: {
name: 'Scale split steps with zoom?',
control: { type: 'boolean' },
},
swapAxis: {
name: 'Swap time and space axis?',
control: { type: 'boolean' },
},
},
} as Meta<typeof SplitSpaceTimeChartWrapper>;

export const Default = {
name: 'Default arguments',
args: {
splitPoints: 'A,C,E',
splitHeight: 100,
scaleWithZoom: false,
swapAxis: false,
},
};
Loading

0 comments on commit 2f5e675

Please sign in to comment.