Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ui-manchette: refacto how waypoints are displayed #847

Merged
merged 5 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 23 additions & 58 deletions ui-manchette-with-spacetimechart/src/__tests__/helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { describe, it, expect } from 'vitest';

import { BASE_WAYPOINT_HEIGHT } from '../consts';
import {
getDisplayedWaypoints,
calcWaypointsHeight,
getWaypointsWithPosition,
getScales,
} from '../helpers';
import { computeWaypointsToDisplay, getScales } from '../helpers';

// Assuming these types from your code

Expand All @@ -17,85 +12,55 @@ const mockedWaypoints = [
{ position: 20, id: 'waypoint-3' },
];

describe('getDisplayedWaypoints', () => {
it('should display all points for non-proportional display', () => {
const result = getDisplayedWaypoints(mockedWaypoints, {
describe('computeWaypointsToDisplay', () => {
it('should ensure that a empty array is returned when there is only 1 waypoint', () => {
const result = computeWaypointsToDisplay([mockedWaypoints[0]], {
height: 500,
isProportional: false,
isProportional: true,
yZoom: 1,
});
expect(result).toHaveLength(mockedWaypoints.length);
result.forEach((waypoint) => {
expect(waypoint.display).toBe(true);
});
expect(result.length).toBe(0);
});

it('should calculate proportional display correctly', () => {
const result = getDisplayedWaypoints(mockedWaypoints, {
height: 500,
isProportional: true,
it('should display all points for non-proportional display', () => {
const result = computeWaypointsToDisplay(mockedWaypoints, {
height: 100,
isProportional: false,
yZoom: 1,
});
expect(result).toHaveLength(mockedWaypoints.length);
expect(result[0].display).toBe(true);
expect(result[1].display).toBe(true);
expect(result[0].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`);
expect(result[1].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`);
});

it('should ensure the last point is always displayed', () => {
const result = getDisplayedWaypoints(mockedWaypoints, {
height: 500,
it('should correctly filter waypoints', () => {
const result = computeWaypointsToDisplay(mockedWaypoints, {
height: 100,
isProportional: true,
yZoom: 1,
});
expect(result[result.length - 1].display).toBe(true);
expect(result).toHaveLength(2);
});
});

describe('calcWaypointsHeight', () => {
const mockStyledWaypoints = mockedWaypoints.map((waypoint) => ({
...waypoint,
styles: {},
display: true,
}));
it('Should ensure that a empty array is return when there is only 1 waypoint', () => {
const result = calcWaypointsHeight([mockedWaypoints[0]], {
height: 500,
isProportional: true,
yZoom: 1,
});
expect(result.length).toBe(0);
});
it('should return correct heights for proportional display', () => {
const result = calcWaypointsHeight(mockStyledWaypoints, {
const result = computeWaypointsToDisplay(mockedWaypoints, {
height: 500,
isProportional: true,
yZoom: 2,
});
expect(result).toHaveLength(mockStyledWaypoints.length);
expect(result).toHaveLength(mockedWaypoints.length);
expect(result[0].styles?.height).toBe(`428px`);
expect(result[1].styles?.height).toBe(`428px`);
expect(result[2].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`);
});

it('should return correct heights for non-proportional display', () => {
const result = calcWaypointsHeight(mockStyledWaypoints, {
height: 500,
isProportional: false,
it('should ensure the last point is always displayed', () => {
const result = computeWaypointsToDisplay(mockedWaypoints, {
height: 100,
isProportional: true,
yZoom: 1,
});
expect(result[0].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`);
expect(result[1].styles?.height).toBe(`${BASE_WAYPOINT_HEIGHT}px`);
});
});

describe('getWaypointsWithPosition', () => {
it('should return waypoints with position and label', () => {
const result = getWaypointsWithPosition(mockedWaypoints);
expect(result).toHaveLength(mockedWaypoints.length);
result.forEach((waypoint, index) => {
expect(waypoint.id).toBe(mockedWaypoints[index].id);
expect(waypoint.position).toBe(mockedWaypoints[index].position);
});
expect(result.some((waypoint) => waypoint.id === 'waypoint-3')).toBe(true);
});
});

Expand Down
119 changes: 47 additions & 72 deletions ui-manchette-with-spacetimechart/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { InteractiveWaypoint, Waypoint } from '@osrd-project/ui-manchette/dist/types';
import type { OperationalPoint } from '@osrd-project/ui-spacetimechart/dist/lib/types';
import { clamp } from 'lodash';

import {
Expand All @@ -13,106 +12,82 @@ import { calcTotalDistance, getHeightWithoutLastWaypoint } from './utils';

type WaypointsOptions = { isProportional: boolean; yZoom: number; height: number };

type VisibilityFilterOptions<T> = {
elements: T[];
getPosition: (element: T) => number;
getWeight: (element: T) => number | undefined;
minSpace: number;
};
export const filterVisibleElements = (
elements: Waypoint[],
totalDistance: number,
heightWithoutFinalWaypoint: number,
minSpace: number
): Waypoint[] => {
const getPosition = (waypoint: Waypoint) =>
(waypoint.position / totalDistance) * heightWithoutFinalWaypoint;

export const filterVisibleElements = <T>({
elements,
getPosition,
getWeight,
minSpace,
}: VisibilityFilterOptions<T>): T[] => {
const firstElement = elements.at(0);
const lastElement = elements.at(-1);
if (!firstElement || !lastElement) return elements;

const sortedElements = [...elements].sort((a, b) => (getWeight(b) ?? 0) - (getWeight(a) ?? 0));
const displayedElements: { element: T; position: number }[] = [
{ element: firstElement, position: getPosition(firstElement) },
{ element: lastElement, position: getPosition(lastElement) },
];
const sortedElements = [...elements].sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0));
const displayedElements: Waypoint[] = [firstElement, lastElement];

for (const element of sortedElements) {
const position = getPosition(element);

const hasSpace = !displayedElements.some(
(displayed) => Math.abs(position - displayed.position) < minSpace
(displayed) => Math.abs(getPosition(element) - getPosition(displayed)) < minSpace
);

if (hasSpace) {
displayedElements.push({ element, position });
displayedElements.push(element);
}
}

return displayedElements
.sort((a, b) => getPosition(a.element) - getPosition(b.element))
.map(({ element }) => element);
return displayedElements.sort((a, b) => a.position - b.position);
};

export const getDisplayedWaypoints = (
export const computeWaypointsToDisplay = (
waypoints: Waypoint[],
{ height, isProportional, yZoom }: WaypointsOptions
): InteractiveWaypoint[] => {
if (!isProportional || waypoints.length === 0) {
return waypoints.map((waypoint) => ({ ...waypoint, display: true }));
}

const totalDistance = calcTotalDistance(waypoints);
const heightWithoutFinalWaypoint = getHeightWithoutLastWaypoint(height);
const minSpace = BASE_WAYPOINT_HEIGHT / yZoom;

const displayedWaypoints = filterVisibleElements({
elements: waypoints,
getPosition: (waypoint) => (waypoint.position / totalDistance) * heightWithoutFinalWaypoint,
getWeight: (waypoint) => waypoint.weight,
minSpace,
});

return displayedWaypoints.map((waypoint) => ({ ...waypoint, display: true }));
};

export const calcWaypointsHeight = (
waypoints: InteractiveWaypoint[],
{ height, isProportional, yZoom }: WaypointsOptions
) => {
if (waypoints.length < 2) return [];

const totalDistance = calcTotalDistance(waypoints);
const heightWithoutFinalWaypoint = getHeightWithoutLastWaypoint(height);

return waypoints.map((waypoint, index) => {
const nextWaypoint = waypoints.at(index + 1);
if (!nextWaypoint) {
return { ...waypoint, styles: { height: `${BASE_WAYPOINT_HEIGHT}px` } };
}
if (isProportional) {
// display all waypoints in linear mode
if (!isProportional) {
return waypoints.map((waypoint, index) => {
const nextWaypoint = waypoints.at(index + 1);
return {
...waypoint,
styles: {
height: `${
((nextWaypoint.position - waypoint.position) / totalDistance) *
heightWithoutFinalWaypoint *
yZoom
}px`,
},
styles: { height: `${BASE_WAYPOINT_HEIGHT * (nextWaypoint ? yZoom : 1)}px` },
};
} else {
return { ...waypoint, styles: { height: `${BASE_WAYPOINT_HEIGHT * yZoom}px` } };
}
});
}

// in proportional mode, hide some waypoints to avoid collisions
const minSpace = BASE_WAYPOINT_HEIGHT / yZoom;

const filteredWaypoints = filterVisibleElements(
waypoints,
totalDistance,
heightWithoutFinalWaypoint,
minSpace
);

return filteredWaypoints.map((waypoint, index) => {
const nextWaypoint = filteredWaypoints.at(index + 1);
return {
...waypoint,
styles: {
height: !nextWaypoint
? `${BASE_WAYPOINT_HEIGHT}px`
: `${
((nextWaypoint.position - waypoint.position) / totalDistance) *
heightWithoutFinalWaypoint *
yZoom
}px`,
},
};
});
};

export const getWaypointsWithPosition = (waypoints: InteractiveWaypoint[]): OperationalPoint[] =>
waypoints.map((point) => ({
id: point.id,
label: point.id,
position: point.position,
importanceLevel: 1,
}));

export const getScales = (
waypoints: Waypoint[],
{ height, isProportional, yZoom }: WaypointsOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import type {
import usePaths from './usePaths';
import { MAX_ZOOM_Y, MIN_ZOOM_Y, ZOOM_Y_DELTA, DEFAULT_ZOOM_MS_PER_PX } from '../consts';
import {
getDisplayedWaypoints,
getWaypointsWithPosition as getOperationalPointWithPosition,
computeWaypointsToDisplay,
getScales,
calcWaypointsHeight,
zoomX,
zoomValueToTimeScale,
timeScaleToZoomValue,
Expand Down Expand Up @@ -59,16 +57,18 @@ const useManchettesWithSpaceTimeChart = (
const paths = usePaths(projectPathTrainResult, selectedTrain);

const waypointsToDisplay = useMemo(
() => getDisplayedWaypoints(waypoints, { height, isProportional, yZoom }),
() => computeWaypointsToDisplay(waypoints, { height, isProportional, yZoom }),
[waypoints, height, isProportional, yZoom]
);
const waypointWithHeight = useMemo(
() => calcWaypointsHeight(waypointsToDisplay, { height, isProportional, yZoom }),
[waypointsToDisplay, height, yZoom, isProportional]
);

const operationalPointsWithPosition = useMemo(
() => getOperationalPointWithPosition(waypointsToDisplay),
const simplifiedWaypoints = useMemo(
() =>
waypointsToDisplay.map((point) => ({
id: point.id,
label: point.id,
position: point.position,
importanceLevel: 1,
})),
[waypointsToDisplay]
);

Expand Down Expand Up @@ -148,13 +148,13 @@ const useManchettesWithSpaceTimeChart = (
}, []);

const computedScales = useMemo(
() => getScales(operationalPointsWithPosition, { height, isProportional, yZoom }),
[operationalPointsWithPosition, height, isProportional, yZoom]
() => getScales(simplifiedWaypoints, { height, isProportional, yZoom }),
[simplifiedWaypoints, height, isProportional, yZoom]
);

const manchetteProps = useMemo(
() => ({
waypoints: waypointWithHeight,
waypoints: waypointsToDisplay,
zoomYIn,
zoomYOut,
resetZoom,
Expand All @@ -163,7 +163,7 @@ const useManchettesWithSpaceTimeChart = (
isProportional,
yOffset,
}),
[waypointWithHeight, zoomYIn, zoomYOut, resetZoom, toggleMode, yZoom, isProportional, yOffset]
[waypointsToDisplay, zoomYIn, zoomYOut, resetZoom, toggleMode, yZoom, isProportional, yOffset]
);

const handleXZoom = useCallback(
Expand All @@ -178,7 +178,7 @@ const useManchettesWithSpaceTimeChart = (

const spaceTimeChartProps = useMemo(
() => ({
operationalPoints: operationalPointsWithPosition,
operationalPoints: simplifiedWaypoints,
spaceScales: computedScales,
timeScale: zoomValueToTimeScale(xZoom),
paths,
Expand Down Expand Up @@ -220,7 +220,7 @@ const useManchettesWithSpaceTimeChart = (
},
}),
[
operationalPointsWithPosition,
simplifiedWaypoints,
computedScales,
xZoom,
paths,
Expand Down
Loading