diff --git a/ui-spacetimechart/src/__tests__/scales.spec.ts b/ui-spacetimechart/src/__tests__/scales.spec.ts index b7db9719..d746eb3d 100644 --- a/ui-spacetimechart/src/__tests__/scales.spec.ts +++ b/ui-spacetimechart/src/__tests__/scales.spec.ts @@ -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]); }); }); @@ -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', () => { diff --git a/ui-spacetimechart/src/components/PathLayer.tsx b/ui-spacetimechart/src/components/PathLayer.tsx index 3b2bf112..f562d0af 100644 --- a/ui-spacetimechart/src/components/PathLayer.tsx +++ b/ui-spacetimechart/src/components/PathLayer.tsx @@ -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; @@ -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), @@ -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(); + }); } } }); diff --git a/ui-spacetimechart/src/components/SpaceGraduations.tsx b/ui-spacetimechart/src/components/SpaceGraduations.tsx index d6462c82..691af4f1 100644 --- a/ui-spacetimechart/src/components/SpaceGraduations.tsx +++ b/ui-spacetimechart/src/components/SpaceGraduations.tsx @@ -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( @@ -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([]); diff --git a/ui-spacetimechart/src/hooks/useCanvas.ts b/ui-spacetimechart/src/hooks/useCanvas.ts index 90f27b01..c240c097 100644 --- a/ui-spacetimechart/src/hooks/useCanvas.ts +++ b/ui-spacetimechart/src/hooks/useCanvas.ts @@ -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); diff --git a/ui-spacetimechart/src/lib/types.ts b/ui-spacetimechart/src/lib/types.ts index 10206377..ed89d662 100644 --- a/ui-spacetimechart/src/lib/types.ts +++ b/ui-spacetimechart/src/lib/types.ts @@ -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; @@ -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: diff --git a/ui-spacetimechart/src/stories/lib/paths.ts b/ui-spacetimechart/src/stories/lib/paths.ts index 16d52436..549a721e 100644 --- a/ui-spacetimechart/src/stories/lib/paths.ts +++ b/ui-spacetimechart/src/stories/lib/paths.ts @@ -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( diff --git a/ui-spacetimechart/src/stories/split.stories.tsx b/ui-spacetimechart/src/stories/split.stories.tsx new file mode 100644 index 00000000..4d01085b --- /dev/null +++ b/ui-spacetimechart/src/stories/split.stories.tsx @@ -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( + (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 ( +
+ { + 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) => ( + + ))} + {fullSplitPoints.map(({ position }, i) => ( + + ))} + +
+ ); +}; + +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; + +export const Default = { + name: 'Default arguments', + args: { + splitPoints: 'A,C,E', + splitHeight: 100, + scaleWithZoom: false, + swapAxis: false, + }, +}; diff --git a/ui-spacetimechart/src/utils/paths.ts b/ui-spacetimechart/src/utils/paths.ts index 1fb8cd78..fddbeda5 100644 --- a/ui-spacetimechart/src/utils/paths.ts +++ b/ui-spacetimechart/src/utils/paths.ts @@ -1,4 +1,4 @@ -import type { DataPoint, Direction, PathData } from '../lib/types'; +import type { DataPoint, Direction, PathData, SpaceToPixel } from '../lib/types'; /** * This function takes a path, a point index and looks forward in the points order for the first @@ -29,3 +29,20 @@ export function getPathDirection( return 'still'; } + +/** + * This function takes a SpaceToPixel function and a position, and checks if the function returns + * the same pixel for the position, starting from both sides. It then returns one or two pixel + * positions accordingly. + */ +export function getSpacePixels( + getSpacePixel: SpaceToPixel, + position: number +): [number] | [number, number] { + const spacePixelFromStart = getSpacePixel(position); + const spacePixelFromEnd = getSpacePixel(position, true); + + return spacePixelFromStart === spacePixelFromEnd + ? [spacePixelFromStart] + : [spacePixelFromStart, spacePixelFromEnd]; +} diff --git a/ui-spacetimechart/src/utils/scales.ts b/ui-spacetimechart/src/utils/scales.ts index 8a16d838..dbe10027 100644 --- a/ui-spacetimechart/src/utils/scales.ts +++ b/ui-spacetimechart/src/utils/scales.ts @@ -107,17 +107,26 @@ export function spaceScalesToBinaryTree( * * Also, if the position is lower than the tree's min (tree.from), then the first leaf is returned * and if it is higher than the max (tree.to), the last leaf is returned. + * + * Finally, if pickLast is truthy, then it returns the last leaf node that contains that position + * instead of the first one. It is very important when there are flat sections. */ export function getNormalizedScaleAtPosition( position: number, - tree: NormalizedScaleTree + tree: NormalizedScaleTree, + pickLast?: boolean ): NormalizedScale { position = clamp(position, tree.from, tree.to); let node = tree; while ('limit' in node) { - if (position <= node.limit) node = node.left; - else node = node.right; + if (!pickLast) { + if (position <= node.limit) node = node.left; + else node = node.right; + } else { + if (position >= node.limit) node = node.right; + else node = node.left; + } } return node; } @@ -162,8 +171,12 @@ export function getSpaceToPixel( pixelOffset: number, binaryTree: NormalizedScaleTree ): SpaceToPixel { - return (position: number) => { - const { from, pixelFrom, coefficient } = getNormalizedScaleAtPosition(position, binaryTree); + return (position: number, fromEnd?: boolean) => { + const { from, pixelFrom, coefficient } = getNormalizedScaleAtPosition( + position, + binaryTree, + fromEnd + ); return pixelOffset + pixelFrom + (position - from) / coefficient; }; } @@ -243,11 +256,9 @@ export function getSpaceBreakpoints(from: number, to: number, tree: NormalizedSc to = Math.min(to, tree.to); let fromScale = getNormalizedScaleAtPosition(from, tree) as NormalizedScale; - let lastValue = from; const res: number[] = []; while (fromScale.to < to) { - if (fromScale.to !== lastValue) res.push(fromScale.to); - lastValue = fromScale.to; + res.push(fromScale.to); fromScale = fromScale.next as NormalizedScale; }