Skip to content

Commit

Permalink
ui-spacetimechart: fixes blurry graduations
Browse files Browse the repository at this point in the history
This commit fixes OpenRailAssociation/osrd#10662.

It ensures vertical and horizontal graduation lines are as thin as
possible, so that they are as crisp as they can be.

Signed-off-by: Alexis Jacomy <[email protected]>
Co-authored-by: Simon Ser <[email protected]>
  • Loading branch information
jacomyal and emersion committed Feb 21, 2025
1 parent 661d4e4 commit 6c3c252
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 21 deletions.
39 changes: 38 additions & 1 deletion ui-spacetimechart/src/__tests__/canvas.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { getAliasedDiscShape } from '../utils/canvas';
import { getAliasedDiscShape, getCrispLineCoordinate } from '../utils/canvas';

describe('getAliasedDiscShape', () => {
it('should return return the expected flat matrices', () => {
Expand All @@ -11,3 +11,40 @@ describe('getAliasedDiscShape', () => {
);
});
});

describe('getCrispLineCoordinate', () => {
it.each([1, 3, 99])('should align a %ipx line on a LoDPI device', (lineWidth) => {
const devicePixelRatio = 1;
expect(getCrispLineCoordinate(0, lineWidth, devicePixelRatio)).toEqual(0.5);
expect(getCrispLineCoordinate(0.1, lineWidth, devicePixelRatio)).toEqual(0.5);
expect(getCrispLineCoordinate(0.5, lineWidth, devicePixelRatio)).toEqual(0.5);
expect(getCrispLineCoordinate(0.9, lineWidth, devicePixelRatio)).toEqual(0.5);
expect(getCrispLineCoordinate(1, lineWidth, devicePixelRatio)).toEqual(1.5);
expect(getCrispLineCoordinate(42, lineWidth, devicePixelRatio)).toEqual(42.5);
expect(getCrispLineCoordinate(-0.4, lineWidth, devicePixelRatio)).toEqual(-0.5);
expect(getCrispLineCoordinate(-0.7, lineWidth, devicePixelRatio)).toEqual(-0.5);
});

it.each([2, 4, 64])('should align a 2px line on a LoDPI device', (lineWidth) => {
const devicePixelRatio = 1;
expect(getCrispLineCoordinate(-0.4, lineWidth, devicePixelRatio)).toEqual(0);
expect(getCrispLineCoordinate(0, lineWidth, devicePixelRatio)).toEqual(0);
expect(getCrispLineCoordinate(0.4, lineWidth, devicePixelRatio)).toEqual(0);
expect(getCrispLineCoordinate(0.5, lineWidth, devicePixelRatio)).toEqual(1);
expect(getCrispLineCoordinate(1.5, lineWidth, devicePixelRatio)).toEqual(2);
expect(getCrispLineCoordinate(42, lineWidth, devicePixelRatio)).toEqual(42);
expect(getCrispLineCoordinate(-0.7, lineWidth, devicePixelRatio)).toEqual(-1);
});

it('should align a 2px line on a HiDPI device', () => {
const devicePixelRatio = 2;
const lineWidth = 2;
expect(getCrispLineCoordinate(0.5, lineWidth, devicePixelRatio)).toEqual(0.5);
});

it('should align a 0.5px line on a HiDPI device', () => {
const devicePixelRatio = 2;
const lineWidth = 0.5;
expect(getCrispLineCoordinate(0, lineWidth, devicePixelRatio)).toEqual(0.25);
});
});
9 changes: 7 additions & 2 deletions ui-spacetimechart/src/components/PathLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
type Point,
type SpaceTimeChartContextType,
} from '../lib/types';
import { drawAliasedDisc, drawAliasedLine, drawPathExtremity } from '../utils/canvas';
import {
drawAliasedDisc,
drawAliasedLine,
drawPathExtremity,
getCrispLineCoordinate,
} from '../utils/canvas';
import { indexToColor, hexToRgb } from '../utils/colors';
import { getSpaceBreakpoints } from '../utils/scales';

Expand Down Expand Up @@ -170,7 +175,7 @@ export const PathLayer = ({
if (i) {
const { position: prevPosition, time: prevTime } = a[i - 1];
if (prevPosition === position && stopPositions.has(position)) {
const spacePixel = getSpacePixel(position);
const spacePixel = getCrispLineCoordinate(getSpacePixel(position), ctx.lineWidth);
ctx.beginPath();
if (!swapAxis) {
ctx.moveTo(getTimePixel(prevTime), spacePixel);
Expand Down
3 changes: 2 additions & 1 deletion ui-spacetimechart/src/components/SpaceGraduations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback } from 'react';

import { useDraw } from '../hooks/useCanvas';
import { type DrawingFunction } from '../lib/types';
import { getCrispLineCoordinate } from '../utils/canvas';

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

const spacePixel = getSpacePixel(point.position);
const spacePixel = getCrispLineCoordinate(getSpacePixel(point.position), ctx.lineWidth);

ctx.beginPath();
if (!swapAxis) {
Expand Down
26 changes: 13 additions & 13 deletions ui-spacetimechart/src/components/TimeCaptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useDraw } from '../hooks/useCanvas';
import { MINUTE } from '../lib/consts';
import { type DrawingFunction } from '../lib/types';
import { computeVisibleTimeMarkers } from '../utils/canvas';
import { computeVisibleTimeMarkers, getCrispLineCoordinate } from '../utils/canvas';

const MINUTES_FORMATTER = (t: number) => `:${new Date(t).getMinutes().toString().padStart(2, '0')}`;
const HOURS_FORMATTER = (t: number, pixelsPerMinute: number) => {
Expand Down Expand Up @@ -98,11 +98,13 @@ const TimeCaptions = () => {
if (!showTicks) {
ctx.beginPath();
if (!swapAxis) {
ctx.moveTo(0, spaceAxisSize - CAPTION_SIZE);
ctx.lineTo(timeAxisSize, spaceAxisSize - CAPTION_SIZE);
const y = getCrispLineCoordinate(spaceAxisSize - CAPTION_SIZE, ctx.lineWidth);
ctx.moveTo(0, y);
ctx.lineTo(timeAxisSize, y);
} else {
ctx.moveTo(CAPTION_SIZE, 0);
ctx.lineTo(CAPTION_SIZE, timeAxisSize);
const x = getCrispLineCoordinate(CAPTION_SIZE, ctx.lineWidth);
ctx.moveTo(x, 0);
ctx.lineTo(x, timeAxisSize);
}
ctx.stroke();
}
Expand All @@ -118,21 +120,19 @@ const TimeCaptions = () => {
ctx.textBaseline = 'top';
ctx.fillStyle = styles.color;
ctx.font = `${styles.fontWeight || 'normal'} ${styles.font}`;
const timePixel = getCrispLineCoordinate(getTimePixel(+t), ctx.lineWidth);

if (!swapAxis) {
if (showTicks) {
ctx.strokeStyle = timeCaptionsStyles[1].color;
ctx.moveTo(getTimePixel(+t), spaceAxisSize - CAPTION_SIZE);
ctx.lineTo(getTimePixel(+t), +t % 180000 === 0 ? 8 : 4);
ctx.moveTo(timePixel, spaceAxisSize - CAPTION_SIZE);
ctx.lineTo(timePixel, +t % 180000 === 0 ? 8 : 4);
ctx.stroke();
}
ctx.fillText(
text,
getTimePixel(+t),
spaceAxisSize - CAPTION_SIZE + (styles.topOffset || 0)
);
ctx.fillText(text, timePixel, spaceAxisSize - CAPTION_SIZE + (styles.topOffset || 0));
} else {
ctx.save();
ctx.translate(CAPTION_SIZE - (styles.topOffset || 0), getTimePixel(+t));
ctx.translate(CAPTION_SIZE - (styles.topOffset || 0), timePixel);
ctx.rotate(Math.PI / 2);
ctx.fillText(text, 0, 0);
ctx.restore();
Expand Down
4 changes: 2 additions & 2 deletions ui-spacetimechart/src/components/TimeGraduations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useDraw } from '../hooks/useCanvas';
import { MINUTE } from '../lib/consts';
import { type DrawingFunction } from '../lib/types';
import { computeVisibleTimeMarkers } from '../utils/canvas';
import { computeVisibleTimeMarkers, getCrispLineCoordinate } from '../utils/canvas';

const TimeGraduations = () => {
const drawingFunction = useCallback<DrawingFunction>(
Expand Down Expand Up @@ -53,7 +53,7 @@ const TimeGraduations = () => {
ctx.lineDashOffset = -spacePixelOffset;
}

const timePixel = getTimePixel(+t);
const timePixel = getCrispLineCoordinate(getTimePixel(+t), ctx.lineWidth);
ctx.beginPath();
if (!swapAxis) {
ctx.moveTo(timePixel, 0);
Expand Down
25 changes: 23 additions & 2 deletions ui-spacetimechart/src/utils/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,14 +289,14 @@ export function computeVisibleTimeMarkers<T>(
minT: number,
maxT: number,
timeRanges: number[],
levels: number[],
gridlinesLevels: number[],
formatter: (level: number, i: number) => T = identity
) {
const result: Record<number, T> = {};
const minTLocalOffset = new Date(minT).getTimezoneOffset() * 60 * 1000;

timeRanges.forEach((range, i) => {
const gridlinesLevel = levels[i];
const gridlinesLevel = gridlinesLevels[i];

if (!gridlinesLevel) return;

Expand All @@ -310,3 +310,24 @@ export function computeVisibleTimeMarkers<T>(
});
return result;
}

/**
* To get crisp horizontal or vertical lines on a canvas, we must draw them as thin as possible, in
* terms of actual pixels on screen.
* The best way for this is:
* - To center lines 1, 3, 5... pixels wide in the middle of a pixel
* - To center lines 2, 4, 6... pixels wide between two pixels
* @param rawCoordinate Any input coordinate to fix
* @param lineWidth The width of the line to draw
* @param devicePixelRatio
*/
export function getCrispLineCoordinate(
rawCoordinate: number,
lineWidth: number,
devicePixelRatio = window.devicePixelRatio || 1
): number {
const centerOffset = lineWidth / 2;
return (
Math.round((rawCoordinate - centerOffset) * devicePixelRatio) / devicePixelRatio + centerOffset
);
}

0 comments on commit 6c3c252

Please sign in to comment.