From 6c3c2528f49d339bf0b487539adc3f14bf172c19 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Wed, 19 Feb 2025 09:46:45 +0100 Subject: [PATCH] ui-spacetimechart: fixes blurry graduations 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 Co-authored-by: Simon Ser --- .../src/__tests__/canvas.spec.ts | 39 ++++++++++++++++++- .../src/components/PathLayer.tsx | 9 ++++- .../src/components/SpaceGraduations.tsx | 3 +- .../src/components/TimeCaptions.tsx | 26 ++++++------- .../src/components/TimeGraduations.tsx | 4 +- ui-spacetimechart/src/utils/canvas.ts | 25 +++++++++++- 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/ui-spacetimechart/src/__tests__/canvas.spec.ts b/ui-spacetimechart/src/__tests__/canvas.spec.ts index 617562170..61bfae97f 100644 --- a/ui-spacetimechart/src/__tests__/canvas.spec.ts +++ b/ui-spacetimechart/src/__tests__/canvas.spec.ts @@ -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', () => { @@ -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); + }); +}); diff --git a/ui-spacetimechart/src/components/PathLayer.tsx b/ui-spacetimechart/src/components/PathLayer.tsx index 5e02d0aec..7a51614be 100644 --- a/ui-spacetimechart/src/components/PathLayer.tsx +++ b/ui-spacetimechart/src/components/PathLayer.tsx @@ -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'; @@ -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); diff --git a/ui-spacetimechart/src/components/SpaceGraduations.tsx b/ui-spacetimechart/src/components/SpaceGraduations.tsx index 4801d4c0d..d6462c82f 100644 --- a/ui-spacetimechart/src/components/SpaceGraduations.tsx +++ b/ui-spacetimechart/src/components/SpaceGraduations.tsx @@ -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( @@ -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) { diff --git a/ui-spacetimechart/src/components/TimeCaptions.tsx b/ui-spacetimechart/src/components/TimeCaptions.tsx index 4c10852bc..3ce00c0ed 100644 --- a/ui-spacetimechart/src/components/TimeCaptions.tsx +++ b/ui-spacetimechart/src/components/TimeCaptions.tsx @@ -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) => { @@ -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(); } @@ -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(); diff --git a/ui-spacetimechart/src/components/TimeGraduations.tsx b/ui-spacetimechart/src/components/TimeGraduations.tsx index ad7c6a4b7..6eac84be6 100644 --- a/ui-spacetimechart/src/components/TimeGraduations.tsx +++ b/ui-spacetimechart/src/components/TimeGraduations.tsx @@ -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( @@ -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); diff --git a/ui-spacetimechart/src/utils/canvas.ts b/ui-spacetimechart/src/utils/canvas.ts index a27a8bccb..9c2348adb 100644 --- a/ui-spacetimechart/src/utils/canvas.ts +++ b/ui-spacetimechart/src/utils/canvas.ts @@ -289,14 +289,14 @@ export function computeVisibleTimeMarkers( minT: number, maxT: number, timeRanges: number[], - levels: number[], + gridlinesLevels: number[], formatter: (level: number, i: number) => T = identity ) { const result: Record = {}; const minTLocalOffset = new Date(minT).getTimezoneOffset() * 60 * 1000; timeRanges.forEach((range, i) => { - const gridlinesLevel = levels[i]; + const gridlinesLevel = gridlinesLevels[i]; if (!gridlinesLevel) return; @@ -310,3 +310,24 @@ export function computeVisibleTimeMarkers( }); 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 + ); +}