diff --git a/src/app/storage/defaultDashboard.ts b/src/app/storage/defaultDashboard.ts index 6b1e650..9f9e900 100644 --- a/src/app/storage/defaultDashboard.ts +++ b/src/app/storage/defaultDashboard.ts @@ -70,6 +70,9 @@ export const defaultDashboard: DashboardLayout = { width: 400, height: 600, }, + config: { + enableTurnNames: false, + }, }, { id: 'weather', diff --git a/src/frontend/components/Settings/SettingsLayout.tsx b/src/frontend/components/Settings/SettingsLayout.tsx index 537111e..192920e 100644 --- a/src/frontend/components/Settings/SettingsLayout.tsx +++ b/src/frontend/components/Settings/SettingsLayout.tsx @@ -127,9 +127,6 @@ export const SettingsLayout = () => {
Track Map - - Experimental -
diff --git a/src/frontend/components/Settings/sections/TrackMapSettings.tsx b/src/frontend/components/Settings/sections/TrackMapSettings.tsx index 8cd0380..48a7214 100644 --- a/src/frontend/components/Settings/sections/TrackMapSettings.tsx +++ b/src/frontend/components/Settings/sections/TrackMapSettings.tsx @@ -1,13 +1,26 @@ import { useState } from 'react'; import { BaseSettingsSection } from '../components/BaseSettingsSection'; -import { TrackMapWidgetSettings } from '../types'; import { useDashboard } from '@irdashies/context'; +import { ToggleSwitch } from '../components/ToggleSwitch'; + +const SETTING_ID = 'map'; + +interface TrackMapSettings { + enabled: boolean; + config: { + enableTurnNames: boolean; + }; +} + +const defaultConfig: TrackMapSettings['config'] = { + enableTurnNames: false +}; export const TrackMapSettings = () => { const { currentDashboard } = useDashboard(); - const [settings, setSettings] = useState({ - enabled: currentDashboard?.widgets.find(w => w.id === 'map')?.enabled ?? false, - config: currentDashboard?.widgets.find(w => w.id === 'map')?.config ?? {}, + const [settings, setSettings] = useState({ + enabled: currentDashboard?.widgets.find(w => w.id === SETTING_ID)?.enabled ?? false, + config: currentDashboard?.widgets.find(w => w.id === SETTING_ID)?.config as TrackMapSettings['config'] ?? defaultConfig, }); if (!currentDashboard) { @@ -22,13 +35,27 @@ export const TrackMapSettings = () => { onSettingsChange={setSettings} widgetId="map" > -
-

This feature is experimental and may not work as expected.

-
- {/* Add specific settings controls here */} -
- Additional settings will appear here -
+ {(handleConfigChange) => ( +
+
+

This is still a work in progress. There are several tracks still missing, please report any issues/requests.

+
+
+
+ Enable Turn Names +

+ Show turn numbers and names on the track map +

+
+ handleConfigChange({ + enableTurnNames: enabled + })} + /> +
+
+ )} ); }; \ No newline at end of file diff --git a/src/frontend/components/Settings/types.ts b/src/frontend/components/Settings/types.ts index 4177045..12c8dde 100644 --- a/src/frontend/components/Settings/types.ts +++ b/src/frontend/components/Settings/types.ts @@ -31,7 +31,9 @@ export interface WeatherWidgetSettings extends BaseWidgetSettings { } export interface TrackMapWidgetSettings extends BaseWidgetSettings { - // Add specific track map settings here + config: { + enableTurnNames: boolean; + }; } export type InputWidgetSettings = BaseWidgetSettings; diff --git a/src/frontend/components/TrackMap/TrackCanvas.stories.tsx b/src/frontend/components/TrackMap/TrackCanvas.stories.tsx index 466d88c..a5b7122 100644 --- a/src/frontend/components/TrackMap/TrackCanvas.stories.tsx +++ b/src/frontend/components/TrackMap/TrackCanvas.stories.tsx @@ -2,14 +2,24 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { TrackCanvas, TrackDriver } from './TrackCanvas'; import { useEffect, useState } from 'react'; import tracks from './tracks/tracks.json'; -import { BROKEN_TRACKS } from './tracks/broken-tracks'; +import { BROKEN_TRACKS } from './tracks/brokenTracks'; export default { component: TrackCanvas, + args: { + enableTurnNames: false, + debug: true, + }, argTypes: { trackId: { control: { type: 'number' }, }, + enableTurnNames: { + control: { type: 'boolean' }, + }, + debug: { + control: { type: 'boolean' }, + }, }, } as Meta; @@ -358,7 +368,7 @@ const allTrackIds = Object.keys(tracks) .sort((a, b) => a - b); export const AllTracksGrid: Story = { - render: () => { + render: (args) => { const trackSize = 150; return ( @@ -387,6 +397,8 @@ export const AllTracksGrid: Story = { @@ -398,7 +410,7 @@ export const AllTracksGrid: Story = { }; export const BrokenTracksGrid: Story = { - render: () => { + render: (args) => { const trackSize = 200; return ( @@ -427,6 +439,8 @@ export const BrokenTracksGrid: Story = { diff --git a/src/frontend/components/TrackMap/TrackCanvas.tsx b/src/frontend/components/TrackMap/TrackCanvas.tsx index f2389e1..2645e45 100644 --- a/src/frontend/components/TrackMap/TrackCanvas.tsx +++ b/src/frontend/components/TrackMap/TrackCanvas.tsx @@ -2,12 +2,22 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Driver } from '@irdashies/types'; import tracks from './tracks/tracks.json'; import { getColor, getTailwindStyle } from '@irdashies/utils/colors'; -import { shouldShowTrack } from './tracks/broken-tracks'; +import { shouldShowTrack } from './tracks/brokenTracks'; import { TrackDebug } from './TrackDebug'; +import { useStartFinishLine } from './hooks/useStartFinishLine'; +import { + setupCanvasContext, + drawTrack, + drawStartFinishLine, + drawTurnNames, + drawDrivers, +} from './trackDrawingUtils'; export interface TrackProps { trackId: number; drivers: TrackDriver[]; + enableTurnNames?: boolean; + debug?: boolean; } export interface TrackDriver { @@ -36,13 +46,15 @@ export interface TrackDrawing { }[]; } -// currently its a bit messy with the turns, so we disable them for now -const ENABLE_TURNS = true; - const TRACK_DRAWING_WIDTH = 1920; const TRACK_DRAWING_HEIGHT = 1080; -export const TrackCanvas = ({ trackId, drivers }: TrackProps) => { +export const TrackCanvas = ({ + trackId, + drivers, + enableTurnNames, + debug, +}: TrackProps) => { const canvasRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); @@ -81,6 +93,12 @@ export const TrackCanvas = ({ trackId, drivers }: TrackProps) => { return colors; }, [drivers]); + // Get start/finish line calculations + const startFinishLine = useStartFinishLine({ + startFinishPoint: trackDrawing?.startFinish?.point, + trackPathPoints: trackDrawing?.active?.trackPathPoints, + }); + // Position calculation based on the percentage of the track completed const calculatePositions = useMemo(() => { if ( @@ -198,73 +216,38 @@ export const TrackCanvas = ({ trackId, drivers }: TrackProps) => { const offsetX = (rect.width - TRACK_DRAWING_WIDTH * scale) / 2; const offsetY = (rect.height - TRACK_DRAWING_HEIGHT * scale) / 2; - // Save context state - ctx.save(); - - // Apply scaling and centering - ctx.translate(offsetX, offsetY); - ctx.scale(scale, scale); - - // Shadow - ctx.shadowColor = 'black'; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; + // Setup canvas context with scaling and shadow + setupCanvasContext(ctx, scale, offsetX, offsetY); - // Draw track - if (path2DObjects.inside) { - ctx.strokeStyle = 'white'; - ctx.lineWidth = 20; - ctx.stroke(path2DObjects.inside); - } - - // Draw start/finish line - if (path2DObjects.startFinish) { - ctx.lineWidth = 10; - ctx.strokeStyle = getColor('red'); - ctx.stroke(path2DObjects.startFinish); - } - - // Draw turn numbers - if (ENABLE_TURNS) { - trackDrawing.turns?.forEach((turn) => { - if (!turn.content || !turn.x || !turn.y) return; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = 'white'; - ctx.font = '2rem sans-serif'; - ctx.fillText(turn.content, turn.x, turn.y); - }); - } - - // Draw drivers - Object.values(calculatePositions) - .sort((a, b) => Number(a.isPlayer) - Number(b.isPlayer)) // draws player last to be on top - .forEach(({ driver, position }) => { - const color = driverColors[driver.CarIdx]; - if (!color) return; - - ctx.fillStyle = color.fill; - ctx.beginPath(); - ctx.arc(position.x, position.y, 40, 0, 2 * Math.PI); - ctx.fill(); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = color.text; - ctx.font = '2rem sans-serif'; - ctx.fillText(driver.CarNumber, position.x, position.y); - }); + // Draw all elements + drawTrack(ctx, path2DObjects); + drawStartFinishLine(ctx, startFinishLine); + drawTurnNames(ctx, trackDrawing.turns, enableTurnNames); + drawDrivers(ctx, calculatePositions, driverColors); // Restore context state ctx.restore(); - }, [calculatePositions, path2DObjects, trackDrawing?.turns, driverColors, canvasSize]); + }, [ + calculatePositions, + path2DObjects, + trackDrawing?.turns, + driverColors, + canvasSize, + enableTurnNames, + trackDrawing?.startFinish?.point, + trackDrawing?.active?.trackPathPoints, + startFinishLine, + ]); // Development/Storybook mode - show debug info and canvas - if (import.meta.env?.DEV || import.meta.env?.MODE === 'storybook') { + if (debug) { return (
- +
); } @@ -274,7 +257,10 @@ export const TrackCanvas = ({ trackId, drivers }: TrackProps) => { return (
- +
); }; diff --git a/src/frontend/components/TrackMap/TrackDebug.tsx b/src/frontend/components/TrackMap/TrackDebug.tsx index 32d5b0b..24853a2 100644 --- a/src/frontend/components/TrackMap/TrackDebug.tsx +++ b/src/frontend/components/TrackMap/TrackDebug.tsx @@ -1,5 +1,5 @@ import { TrackDrawing } from './TrackCanvas'; -import { getBrokenTrackInfo } from './tracks/broken-tracks'; +import { getBrokenTrackInfo } from './tracks/brokenTracks'; interface TrackDebugProps { trackId: number; diff --git a/src/frontend/components/TrackMap/TrackMap.tsx b/src/frontend/components/TrackMap/TrackMap.tsx index 342db3b..c329c9d 100644 --- a/src/frontend/components/TrackMap/TrackMap.tsx +++ b/src/frontend/components/TrackMap/TrackMap.tsx @@ -1,12 +1,23 @@ import { useTrackId } from './hooks/useTrackId'; import { useDriverProgress } from './hooks/useDriverProgress'; +import { useTrackMapSettings } from './hooks/useTrackMapSettings'; import { TrackCanvas } from './TrackCanvas'; +const debug = import.meta.env.DEV || import.meta.env.MODE === 'storybook'; + export const TrackMap = () => { const trackId = useTrackId(); const driversTrackData = useDriverProgress(); + const settings = useTrackMapSettings(); if (!trackId) return <>; - return ; + return ( + + ); }; diff --git a/src/frontend/components/TrackMap/hooks/useStartFinishLine.ts b/src/frontend/components/TrackMap/hooks/useStartFinishLine.ts new file mode 100644 index 0000000..9a8508a --- /dev/null +++ b/src/frontend/components/TrackMap/hooks/useStartFinishLine.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; + +interface StartFinishPoint { + x: number; + y: number; +} + +interface TrackPathPoint { + x: number; + y: number; +} + +interface StartFinishLine { + point: StartFinishPoint; + perpendicular: { x: number; y: number }; +} + +interface UseStartFinishLineProps { + startFinishPoint?: { x?: number; y?: number; length?: number } | null; + trackPathPoints?: TrackPathPoint[]; +} + +export const useStartFinishLine = ({ + startFinishPoint, + trackPathPoints, +}: UseStartFinishLineProps): StartFinishLine | null => { + return useMemo(() => { + if ( + startFinishPoint?.x === undefined || + startFinishPoint?.y === undefined || + !trackPathPoints + ) { + return null; + } + + let closestIndex = 0; + let minDistance = Infinity; + + // Find the closest point on the track path to the start/finish point + for (let i = 0; i < trackPathPoints.length; i++) { + const point = trackPathPoints[i]; + const distance = Math.sqrt( + Math.pow(point.x - startFinishPoint.x, 2) + + Math.pow(point.y - startFinishPoint.y, 2) + ); + if (distance < minDistance) { + minDistance = distance; + closestIndex = i; + } + } + + // Calculate the tangent direction at the closest point + const tangent = { x: 0, y: 0 }; + if (closestIndex > 0 && closestIndex < trackPathPoints.length - 1) { + // Use points on either side to calculate tangent + const prev = trackPathPoints[closestIndex - 1]; + const next = trackPathPoints[closestIndex + 1]; + tangent.x = next.x - prev.x; + tangent.y = next.y - prev.y; + } else if (closestIndex === 0) { + // At the start, use the next point + const next = trackPathPoints[1]; + tangent.x = next.x - trackPathPoints[0].x; + tangent.y = next.y - trackPathPoints[0].y; + } else { + // At the end, use the previous point + const prev = trackPathPoints[trackPathPoints.length - 2]; + tangent.x = trackPathPoints[trackPathPoints.length - 1].x - prev.x; + tangent.y = trackPathPoints[trackPathPoints.length - 1].y - prev.y; + } + + // Normalize the tangent vector + const tangentLength = Math.sqrt( + tangent.x * tangent.x + tangent.y * tangent.y + ); + tangent.x /= tangentLength; + tangent.y /= tangentLength; + + // Calculate the perpendicular vector (rotate 90 degrees) + const perpendicular = { x: -tangent.y, y: tangent.x }; + + return { + point: { x: startFinishPoint.x, y: startFinishPoint.y }, + perpendicular, + }; + }, [startFinishPoint, trackPathPoints]); +}; \ No newline at end of file diff --git a/src/frontend/components/TrackMap/hooks/useTrackMapSettings.tsx b/src/frontend/components/TrackMap/hooks/useTrackMapSettings.tsx new file mode 100644 index 0000000..7b5d4ee --- /dev/null +++ b/src/frontend/components/TrackMap/hooks/useTrackMapSettings.tsx @@ -0,0 +1,28 @@ +import { useDashboard } from '@irdashies/context'; + +interface TrackMapSettings { + enabled: boolean; + config: { + enableTurnNames: boolean; + }; +} + +export const useTrackMapSettings = () => { + const { currentDashboard } = useDashboard(); + + const settings = currentDashboard?.widgets.find( + (widget) => widget.id === 'map' + )?.config; + + // Add type guard to ensure settings matches expected shape + if ( + settings && + typeof settings === 'object' && + 'enableTurnNames' in settings && + typeof settings.enableTurnNames === 'boolean' + ) { + return settings as TrackMapSettings['config']; + } + + return undefined; +}; diff --git a/src/frontend/components/TrackMap/trackDrawingUtils.ts b/src/frontend/components/TrackMap/trackDrawingUtils.ts new file mode 100644 index 0000000..cab1406 --- /dev/null +++ b/src/frontend/components/TrackMap/trackDrawingUtils.ts @@ -0,0 +1,102 @@ +import { getColor } from '@irdashies/utils/colors'; +import { TrackDrawing, TrackDriver } from './TrackCanvas'; + +export const setupCanvasContext = ( + ctx: CanvasRenderingContext2D, + scale: number, + offsetX: number, + offsetY: number +) => { + ctx.save(); + ctx.translate(offsetX, offsetY); + ctx.scale(scale, scale); + + // Apply shadow + ctx.shadowColor = 'black'; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; +}; + +export const drawTrack = ( + ctx: CanvasRenderingContext2D, + path2DObjects: { inside: Path2D | null } +) => { + if (!path2DObjects.inside) return; + + // Draw black outline first + ctx.strokeStyle = 'black'; + ctx.lineWidth = 40; + ctx.stroke(path2DObjects.inside); + + // Draw white track on top + ctx.strokeStyle = 'white'; + ctx.lineWidth = 20; + ctx.stroke(path2DObjects.inside); +}; + +export const drawStartFinishLine = ( + ctx: CanvasRenderingContext2D, + startFinishLine: { point: { x: number; y: number }; perpendicular: { x: number; y: number } } | null +) => { + if (!startFinishLine) return; + + const lineLength = 60; // Length of the start/finish line + const { point: sfPoint, perpendicular } = startFinishLine; + + // Calculate the start and end points of the line + const startX = sfPoint.x - (perpendicular.x * lineLength) / 2; + const startY = sfPoint.y - (perpendicular.y * lineLength) / 2; + const endX = sfPoint.x + (perpendicular.x * lineLength) / 2; + const endY = sfPoint.y + (perpendicular.y * lineLength) / 2; + + ctx.lineWidth = 20; + ctx.strokeStyle = getColor('red'); + ctx.lineCap = 'square'; + + // Draw the perpendicular line + ctx.beginPath(); + ctx.moveTo(startX, startY); + ctx.lineTo(endX, endY); + ctx.stroke(); +}; + +export const drawTurnNames = ( + ctx: CanvasRenderingContext2D, + turns: TrackDrawing['turns'], + enableTurnNames: boolean | undefined +) => { + if (!enableTurnNames || !turns) return; + + turns.forEach((turn) => { + if (!turn.content || !turn.x || !turn.y) return; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'white'; + ctx.font = '2rem sans-serif'; + ctx.fillText(turn.content, turn.x, turn.y); + }); +}; + +export const drawDrivers = ( + ctx: CanvasRenderingContext2D, + calculatePositions: Record, + driverColors: Record +) => { + Object.values(calculatePositions) + .sort((a, b) => Number(a.isPlayer) - Number(b.isPlayer)) // draws player last to be on top + .forEach(({ driver, position }) => { + const color = driverColors[driver.CarIdx]; + if (!color) return; + + ctx.fillStyle = color.fill; + ctx.beginPath(); + ctx.arc(position.x, position.y, 40, 0, 2 * Math.PI); + ctx.fill(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = color.text; + ctx.font = '2rem sans-serif'; + ctx.fillText(driver.CarNumber, position.x, position.y); + }); +}; \ No newline at end of file diff --git a/src/frontend/components/TrackMap/tracks/broken-tracks.ts b/src/frontend/components/TrackMap/tracks/brokenTracks.ts similarity index 93% rename from src/frontend/components/TrackMap/tracks/broken-tracks.ts rename to src/frontend/components/TrackMap/tracks/brokenTracks.ts index 611d0f2..86ae351 100644 --- a/src/frontend/components/TrackMap/tracks/broken-tracks.ts +++ b/src/frontend/components/TrackMap/tracks/brokenTracks.ts @@ -1,5 +1,3 @@ -import { TrackDrawing } from '../TrackCanvas'; - export interface BrokenTrack { id: number; name: string; @@ -67,7 +65,10 @@ export const getBrokenTrackInfo = (trackId: number): BrokenTrack | undefined => * In production, broken tracks are hidden * In development/storybook, all tracks are available */ -export const shouldShowTrack = (trackId: number, trackDrawing: TrackDrawing): boolean => { +export const shouldShowTrack = ( + trackId: number, + trackDrawing: { startFinish?: { point?: unknown }; active?: { inside?: unknown } } | null | undefined +): boolean => { // In development or storybook, show all tracks (including broken ones) if (import.meta.env?.DEV || import.meta.env?.MODE === 'storybook') { return true;