diff --git a/CHANGELOG.md b/CHANGELOG.md index 4342defed..176516800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Persisted cross-game race results with qualifying, podium, fastest-lap, pit, strategy, and position-timeline summaries, plus idempotent historical backfill - Configure driver-profile AI output tokens with provider-advertised limits - Use simulator-independent semantic telemetry for live dashboards while keeping native packet inspection in the development panel and recording bytes unchanged +- Toggle ACC and AC Evo reference racing lines alongside other Analyse overlays in both 2D and 3D views - Detect imported file contents before accepting ZIP/BIN session data and reject unrelated archives ### Fixes diff --git a/bun.lock b/bun.lock index 84ccea018..9135d1a1f 100644 --- a/bun.lock +++ b/bun.lock @@ -861,7 +861,7 @@ "@storybook/addon-docs": ["@storybook/addon-docs@10.5.5", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.5.5", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.5.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.5" }, "optionalPeers": ["@types/react"] }, "sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg=="], - "@storybook/builder-vite": ["@storybook/builder-vite@10.5.7", "", { "dependencies": { "@storybook/csf-plugin": "10.5.7", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.5.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-fShF/aQaITqcJuMCLr42BGNUAbhDi4IboqvlbZqXAwgrrTslnZEUnY8GcEcvpZmjl11VwlmazhMJdH50fIgBPg=="], + "@storybook/builder-vite": ["@storybook/builder-vite@10.5.8", "", { "dependencies": { "@storybook/csf-plugin": "10.5.8", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.5.8", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-UeRnn7yT55WmBlHNOQzLrvN7vsHEvVgIukhKDO+4cMbGXN87wZkbxhx6NstpuXRH8OxGqwKS0SZNVp+SC1ftLQ=="], "@storybook/csf-plugin": ["@storybook/csf-plugin@10.5.5", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.5.5", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA=="], @@ -869,11 +869,11 @@ "@storybook/icons": ["@storybook/icons@2.1.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg=="], - "@storybook/react": ["@storybook/react@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.5.7", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7", "typescript": ">= 4.9.x" }, "optionalPeers": ["@types/react", "@types/react-dom", "typescript"] }, "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g=="], + "@storybook/react": ["@storybook/react@10.5.8", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.5.8", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.8", "typescript": ">= 4.9.x" }, "optionalPeers": ["@types/react", "@types/react-dom", "typescript"] }, "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg=="], "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.5.5", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.5" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg=="], - "@storybook/react-vite": ["@storybook/react-vite@10.5.7", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.5.7", "@storybook/react": "10.5.7", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-eEo3eVa2pvqrzQukKxAzx7YvswDAA1s6k/y+tdMxmRvWyHX6QEOsb9Tda6wcVaa7c8BeJM7Ggq+289cRMTH6Iw=="], + "@storybook/react-vite": ["@storybook/react-vite@10.5.8", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.5.8", "@storybook/react": "10.5.8", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.8", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-ioMJGi4YzueGsJBlYio+2+UhfCFB9QV5Bs1lOilkek+a4BZgKJl0D1mVSJl6k96stQBPZmLgI9/l0hLVcUL6Kg=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -2129,7 +2129,7 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], - "storybook": ["storybook@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg=="], + "storybook": ["storybook@10.5.8", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA=="], "stream-parser": ["stream-parser@0.3.1", "", { "dependencies": { "debug": "2" } }, "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ=="], @@ -2425,9 +2425,9 @@ "@sindresorhus/transliterate/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@10.5.7", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.5.7", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag=="], + "@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@10.5.8", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.5.8", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w=="], - "@storybook/react/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.5.7", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA=="], + "@storybook/react/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.5.8", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.8" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], diff --git a/client/messages/de.json b/client/messages/de.json index 22b9c9ad6..319d84318 100644 --- a/client/messages/de.json +++ b/client/messages/de.json @@ -696,10 +696,11 @@ "track_detail_no_laps_match_filters": "Keine Runden entsprechen den gewählten Filtern", "overlay_follow": "Folgen", "overlay_fixed": "Fest", - "overlay_overlay": "Overlay", + "overlay_overlay": "Overlays", "overlay_inputs": "Eingaben", "overlay_segments": "Segmente", "overlay_sectors": "Sektoren", + "overlay_racing_line": "Ideallinie", "track_detail_class": "Klasse", "track_detail_lap_num": "Runde #", "track_detail_all_cars": "Alle Fahrzeuge", @@ -904,6 +905,7 @@ "carwire_hidden": "Versteckt", "carwire_solid": "Solide", "carwire_wire": "Gitter", + "carwire_view": "Ansicht", "carwire_springs": "Federn", "carwire_inputs": "Eingaben", "carwire_grid": "Raster", diff --git a/client/messages/en.json b/client/messages/en.json index a75762a1e..dd4a7b511 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -696,10 +696,11 @@ "track_detail_no_laps_match_filters": "No laps match the selected filters", "overlay_follow": "Follow", "overlay_fixed": "Fixed", - "overlay_overlay": "Overlay", + "overlay_overlay": "Overlays", "overlay_inputs": "Inputs", "overlay_segments": "Segments", "overlay_sectors": "Sectors", + "overlay_racing_line": "Racing line", "track_detail_class": "Class", "track_detail_lap_num": "Lap #", "track_detail_all_cars": "All cars", @@ -904,6 +905,7 @@ "carwire_hidden": "Hidden", "carwire_solid": "Solid", "carwire_wire": "Wire", + "carwire_view": "View", "carwire_springs": "Springs", "carwire_inputs": "Inputs", "carwire_grid": "Grid", diff --git a/client/src/components/CarWireframe.tsx b/client/src/components/CarWireframe.tsx index 6ae19c7da..f13648ab1 100644 --- a/client/src/components/CarWireframe.tsx +++ b/client/src/components/CarWireframe.tsx @@ -2,9 +2,11 @@ import { useGLTF } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; import { tryGetGame } from "@shared/games/registry"; import { flipBoundaries, needsTrackFlip } from "@shared/racing/tracks/coords"; +import { ChevronDownIcon } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { m } from "@/paraglide/messages"; import type { GameId } from "../../../shared/games/ids"; +import type { TrackMapBoundaries } from "./analyse/track-map/types"; import { type CarModelEnrichment, DEMO_CAR, F1_CAR, getCarModel, loadCarModelConfigs } from "../data/car-models"; import { useSettings } from "../hooks/settings"; import { useLocalStorage } from "../hooks/useLocalStorage"; @@ -16,6 +18,7 @@ import { DEFAULT_TOGGLES, VIEW_PRESETS, type ViewPreset, type ViewToggles } from import { useGameId } from "../stores/game"; import type { SemanticAnalysisFrame } from "./analyse/track-map/types"; import { Button } from "./ui/button"; +import { DropdownMenu } from "./ui/DropdownMenu"; import { CarScene } from "./wireframe/CarScene"; import { ToggleButton } from "./wireframe/ToggleButton"; @@ -42,7 +45,7 @@ export const CarWireframe = React.memo(function CarWireframe({ telemetry: SemanticAnalysisFrame[]; cursorIdx: number; outline: { x: number; z: number }[] | null; - boundaries?: { leftEdge: { x: number; z: number }[]; rightEdge: { x: number; z: number }[] } | null; + boundaries?: Pick | null; carOrdinal?: number; carModel?: CarModelEnrichment & { hasModel: boolean }; tempLabel?: string; @@ -107,8 +110,25 @@ export const CarWireframe = React.memo(function CarWireframe({ if (!boundaries) return null; return needsTrackFlip(gameId) ? flipBoundaries(boundaries) : boundaries; }, [boundaries, gameId]); - - const toggle = (key: keyof ViewToggles) => setToggles((prev) => ({ ...prev, [key]: !prev[key] })); + const viewToggleItems = [ + { key: "springs" as const, label: m.carwire_springs(), available: true }, + { key: "trails" as const, label: m.carwire_trails(), available: true }, + { key: "inputs" as const, label: m.carwire_inputs(), available: true }, + { key: "track" as const, label: m.carwire_track(), available: true }, + { key: "racingLine" as const, label: m.overlay_racing_line(), available: Array.isArray(flippedBoundaries?.raceLine) && flippedBoundaries.raceLine.length > 1 }, + { key: "grid" as const, label: m.carwire_grid(), available: true }, + { key: "drivetrain" as const, label: m.carwire_drive(), available: true }, + { key: "wheelInfo" as const, label: m.carwire_tire_info(), available: true }, + ] + .filter((item) => item.available) + .map((item) => ({ + type: "checkbox" as const, + key: item.key, + label: item.label, + checked: toggles[item.key], + onCheckedChange: (checked: boolean) => setToggles((previous) => ({ ...previous, [item.key]: checked })), + })); + const anyViewToggle = viewToggleItems.some((item) => item.checked); const fpsRef = useRef(null); const fpsFrames = useRef(0); @@ -237,7 +257,7 @@ export const CarWireframe = React.memo(function CarWireframe({ {/* View toggles */} {!hideControls && ( -
+
- {!minimal && toggle("springs")} />} - {!minimal && toggle("trails")} />} - {!minimal && toggle("inputs")} />} - {!minimal && toggle("track")} />} - {!minimal && toggle("grid")} />} - {!minimal && toggle("drivetrain")} />} - {!minimal && toggle("wheelInfo")} />} - {/* Camber toggle intentionally not rendered: ACC is the only game - with camber in telemetry and Kunos currently ships camberRAD[4] - as a zeroed stub. Re-enable when the game writes real values. */} - {minimal && toggle("dimensions")} />} + {!minimal && ( + + {m.carwire_view()} + + + } + items={viewToggleItems} + /> + )} + {minimal && setToggles((previous) => ({ ...previous, dimensions: !previous.dimensions }))} />}
)} diff --git a/client/src/components/analyse/AnalyseTopSection.tsx b/client/src/components/analyse/AnalyseTopSection.tsx index 8786f87f3..ed6d95240 100644 --- a/client/src/components/analyse/AnalyseTopSection.tsx +++ b/client/src/components/analyse/AnalyseTopSection.tsx @@ -2,7 +2,7 @@ import type { GameId } from "../../../../shared/games/ids"; import { type CSSProperties, type RefObject, useEffect, useRef } from "react"; import type { AnalysisHighlight } from "@/components/ai/analysis-types"; import type { SemanticAnalysisFrame } from "./AnalyseSegmentList"; -import type { Point, SectorBoundaries, TrackMapHandle, TrackMapLabel } from "./track-map/types"; +import type { Point, SectorBoundaries, TrackMapBoundaries, TrackMapHandle, TrackMapLabel, TrackOverlayKey, TrackOverlays } from "./track-map/types"; import { m } from "../../paraglide/messages"; import { AnalyseSegmentList } from "./AnalyseSegmentList"; import { AnalyseTrackPanel } from "./AnalyseTrackPanel"; @@ -22,8 +22,7 @@ interface AnalyseTopSectionProps { cursorIdx: number; outline: Point[] | null; mapLabels?: TrackMapLabel[] | null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - boundaries: any; + boundaries: TrackMapBoundaries | null; sectors: SectorBoundaries | null; segments: { type: string; name: string; startFrac: number; endFrac: number }[] | null; currentFrame: SemanticAnalysisFrame | null; @@ -38,10 +37,10 @@ interface AnalyseTopSectionProps { // View toggles rotateWithCar: boolean; - trackOverlay: "none" | "inputs" | "segments" | "sectors"; + trackOverlays: TrackOverlays; mapZoom: number; onRotateWithCarToggle: () => void; - onTrackOverlayCycle: () => void; + onTrackOverlayChange: (overlay: TrackOverlayKey, checked: boolean) => void; onMapZoomChange: (updater: (z: number) => number) => void; // Viz @@ -74,10 +73,10 @@ export function AnalyseTopSection({ aiPanelOpen, aiHighlights, rotateWithCar, - trackOverlay, + trackOverlays, mapZoom, onRotateWithCarToggle, - onTrackOverlayCycle, + onTrackOverlayChange, onMapZoomChange, vizMode, onVizModeChange, @@ -169,10 +168,10 @@ export function AnalyseTopSection({ aiPanelOpen={aiPanelOpen} aiHighlights={aiHighlights} rotateWithCar={rotateWithCar} - trackOverlay={trackOverlay} + trackOverlays={trackOverlays} mapZoom={mapZoom} onRotateWithCarToggle={onRotateWithCarToggle} - onTrackOverlayCycle={onTrackOverlayCycle} + onTrackOverlayChange={onTrackOverlayChange} onMapZoomChange={onMapZoomChange} trackMapRef={trackMapRef} /> diff --git a/client/src/components/analyse/AnalyseTrackMap.tsx b/client/src/components/analyse/AnalyseTrackMap.tsx index faa1ce83a..bccd5edbe 100644 --- a/client/src/components/analyse/AnalyseTrackMap.tsx +++ b/client/src/components/analyse/AnalyseTrackMap.tsx @@ -7,7 +7,7 @@ import { drawStaticTrack } from "./track-map/static-drawing"; import type { TrackMapHandle, TrackMapProps, TrackTransform } from "./track-map/types"; export const AnalyseTrackMap = forwardRef(function AnalyseTrackMap(props, ref) { - const { gameId, telemetry, cursorIdx, outline, mapLabels, boundaries, sectors, segments, highlights, showInputs, showTrace = true, rotateWithCar, zoom = 1 } = props; + const { gameId, telemetry, cursorIdx, outline, mapLabels, boundaries, sectors, segments, highlights, showInputs, showRaceLine = false, showTrace = true, rotateWithCar, zoom = 1 } = props; const canvasRef = useRef(null); const carCanvasRef = useRef(null); const pulseRef = useRef(null); @@ -33,6 +33,7 @@ export const AnalyseTrackMap = forwardRef(functio segments, highlights, showInputs, + showRaceLine, showTrace, rotateWithCar, zoom, @@ -43,7 +44,7 @@ export const AnalyseTrackMap = forwardRef(functio const ctx = getSemanticCanvasContext(carCanvasRef.current); ctx?.clearRect(0, 0, carCanvasRef.current.width, carCanvasRef.current.height); } - }, [gameId, telemetry, resolvedPositions, outline, mapLabels, boundaries, sectors, segments, highlights, showInputs, showTrace, rotateWithCar, zoom]); + }, [gameId, telemetry, resolvedPositions, outline, mapLabels, boundaries, sectors, segments, highlights, showInputs, showRaceLine, showTrace, rotateWithCar, zoom]); const renderOverlayOptions = useCallback( () => ({ diff --git a/client/src/components/analyse/AnalyseTrackPanel.tsx b/client/src/components/analyse/AnalyseTrackPanel.tsx index 7ed7e5e09..5c2ef788f 100644 --- a/client/src/components/analyse/AnalyseTrackPanel.tsx +++ b/client/src/components/analyse/AnalyseTrackPanel.tsx @@ -2,10 +2,22 @@ import type { GameId } from "../../../../shared/games/ids"; import type { RefObject } from "react"; import type { AnalysisHighlight } from "@/components/ai/analysis-types"; import { m } from "../../paraglide/messages"; +import { ChevronDownIcon } from "lucide-react"; import { Compass } from "../Compass"; import { Button } from "../ui/button"; +import { DropdownMenu } from "../ui/DropdownMenu"; import { AnalyseTrackMap } from "./AnalyseTrackMap"; -import type { Point, SectorBoundaries, SemanticAnalysisFrame, TrackMapHandle, TrackMapLabel } from "./track-map/types"; +import { + DEFAULT_TRACK_OVERLAYS, + type Point, + type SectorBoundaries, + type SemanticAnalysisFrame, + type TrackMapBoundaries, + type TrackMapHandle, + type TrackMapLabel, + type TrackOverlayKey, + type TrackOverlays, +} from "./track-map/types"; import { WeatherWidget } from "./WeatherWidget"; interface AnalyseTrackPanelProps { @@ -14,8 +26,7 @@ interface AnalyseTrackPanelProps { cursorIdx: number; outline: Point[] | null; mapLabels?: TrackMapLabel[] | null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - boundaries: any; + boundaries: TrackMapBoundaries | null; sectors: SectorBoundaries | null; segments: { type: string; name: string; startFrac: number; endFrac: number }[] | null; currentFrame: SemanticAnalysisFrame | null; @@ -24,12 +35,12 @@ interface AnalyseTrackPanelProps { aiHighlights?: AnalysisHighlight[] | null; rotateWithCar: boolean; - trackOverlay: "none" | "inputs" | "segments" | "sectors"; + trackOverlays: TrackOverlays; mapZoom: number; /** Live view passes false to draw only track edges, not the driving line. */ showTrace?: boolean; onRotateWithCarToggle: () => void; - onTrackOverlayCycle: () => void; + onTrackOverlayChange?: (overlay: TrackOverlayKey, checked: boolean) => void; onMapZoomChange: (updater: (z: number) => number) => void; trackMapRef?: RefObject; @@ -61,18 +72,31 @@ export function AnalyseTrackPanel({ aiPanelOpen, aiHighlights, rotateWithCar, - trackOverlay, + trackOverlays, mapZoom, showTrace, onRotateWithCarToggle, - onTrackOverlayCycle, + onTrackOverlayChange, onMapZoomChange, trackMapRef, hideSteeringOverlay, weatherBottomRight, }: AnalyseTrackPanelProps) { + const hasRacingLine = Array.isArray(boundaries?.raceLine) && boundaries.raceLine.length > 1; + const anyTrackOverlay = Object.values(trackOverlays).some(Boolean); + const overlayItems = (Object.keys(DEFAULT_TRACK_OVERLAYS) as TrackOverlayKey[]) + .filter((overlay) => overlay !== "racingLine" || hasRacingLine) + .map((overlay) => ({ + type: "checkbox" as const, + key: overlay, + label: overlay === "inputs" ? m.overlay_inputs() : overlay === "segments" ? m.overlay_segments() : overlay === "sectors" ? m.overlay_sectors() : m.overlay_racing_line(), + checked: trackOverlays[overlay], + onCheckedChange: (checked: boolean) => onTrackOverlayChange?.(overlay, checked), + })); + return (
{ if (!rotateWithCar) return; @@ -86,13 +110,14 @@ export function AnalyseTrackPanel({ telemetry={telemetry} cursorIdx={cursorIdx} outline={outline} - mapLabels={trackOverlay === "segments" ? mapLabels : null} + mapLabels={trackOverlays.segments ? mapLabels : null} boundaries={boundaries} - sectors={trackOverlay === "sectors" ? sectors : null} - segments={trackOverlay === "segments" ? segments : null} + sectors={trackOverlays.sectors ? sectors : null} + segments={trackOverlays.segments ? segments : null} highlights={aiPanelOpen ? aiHighlights : null} - showInputs={trackOverlay === "inputs"} + showInputs={trackOverlays.inputs} showTrace={showTrace} + showRaceLine={trackOverlays.racingLine && hasRacingLine} rotateWithCar={rotateWithCar} zoom={mapZoom} /> @@ -109,15 +134,22 @@ export function AnalyseTrackPanel({ > {rotateWithCar ? m.overlay_follow() : m.overlay_fixed()} - {!hideSteeringOverlay && ( - + {!hideSteeringOverlay && onTrackOverlayChange && ( + + {m.overlay_overlay()} + + + } + items={overlayItems} + /> )}
diff --git a/client/src/components/analyse/AnalyseVizPanel.tsx b/client/src/components/analyse/AnalyseVizPanel.tsx index 4fed0af2a..47acacbd5 100644 --- a/client/src/components/analyse/AnalyseVizPanel.tsx +++ b/client/src/components/analyse/AnalyseVizPanel.tsx @@ -87,6 +87,7 @@ export const AnalyseVizPanel = memo(function AnalyseVizPanel({
{visualFrame && ( 1; + const effectiveTrackOverlays = hasRacingLine ? trackOverlays : { ...trackOverlays, racingLine: false }; const loading = lapLoading; const [cursorIdx, setCursorIdx] = useState(0); const [visualTimeFrac, setVisualTimeFrac] = useState(null); @@ -300,9 +302,12 @@ function LapAnalyseInner() { const handleNotesChange = useCallback((notes: string) => updateLapNotesMutation.mutate(notes), [updateLapNotesMutation.mutate]); const handleToggleAi = useCallback(() => setAiPanelOpen((open) => !open), [setAiPanelOpen]); const handleRotateWithCarToggle = useCallback(() => setRotateWithCar((rotate) => !rotate), [setRotateWithCar]); - const handleTrackOverlayCycle = useCallback( - () => setTrackOverlay((overlay) => (overlay === "none" ? "inputs" : overlay === "inputs" ? "segments" : overlay === "segments" ? "sectors" : "none")), - [setTrackOverlay], + const handleTrackOverlayChange = useCallback( + (overlay: TrackOverlayKey, checked: boolean) => { + if (overlay === "racingLine" && !hasRacingLine) return; + setTrackOverlays((current) => ({ ...current, [overlay]: checked })); + }, + [hasRacingLine, setTrackOverlays], ); const { exportingBin, importingBin, ownership, setOwnership, importResult, ibtPreview, handleImportBin, handleCancelIbt, handleCommitIbt, setImportResult } = useAnalyseImports({ @@ -375,10 +380,10 @@ function LapAnalyseInner() { aiPanelOpen, aiHighlights, rotateWithCar, - trackOverlay, + trackOverlays: effectiveTrackOverlays, mapZoom, onRotateWithCarToggle: handleRotateWithCarToggle, - onTrackOverlayCycle: handleTrackOverlayCycle, + onTrackOverlayChange: handleTrackOverlayChange, onMapZoomChange: setMapZoom, vizMode, onVizModeChange: setWheelTab, diff --git a/client/src/components/analyse/track-map/static-drawing.ts b/client/src/components/analyse/track-map/static-drawing.ts index 3204b6be1..383f447ec 100644 --- a/client/src/components/analyse/track-map/static-drawing.ts +++ b/client/src/components/analyse/track-map/static-drawing.ts @@ -23,12 +23,13 @@ export interface StaticTrackOptions { segments: { type: string; name: string; startFrac: number; endFrac: number }[] | null; highlights?: TrackHighlight[] | null; showInputs?: boolean; + showRaceLine?: boolean; showTrace: boolean; rotateWithCar: boolean; zoom: number; } export function drawStaticTrack(options: StaticTrackOptions): { bufferCanvas: HTMLCanvasElement | null; transform: TrackTransform | null } { - const { canvas, telemetry, gameId, resolvedPositions, outline, mapLabels, boundaries, sectors, segments, highlights, showInputs, showTrace, rotateWithCar, zoom } = options; + const { canvas, telemetry, gameId, resolvedPositions, outline, mapLabels, boundaries, sectors, segments, highlights, showInputs, showRaceLine = false, showTrace, rotateWithCar, zoom } = options; const rect = canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return { bufferCanvas: options.bufferCanvas, transform: null }; const w = rect.width; @@ -42,6 +43,7 @@ export function drawStaticTrack(options: StaticTrackOptions): { bufferCanvas: HT const flip = needsTrackFlip(gameId); const flippedLeft = flip && boundaries?.leftEdge ? flipPoints(boundaries.leftEdge) : boundaries?.leftEdge; const flippedRight = flip && boundaries?.rightEdge ? flipPoints(boundaries.rightEdge) : boundaries?.rightEdge; + const raceLine = showRaceLine && Array.isArray(boundaries?.raceLine) && boundaries.raceLine.length > 1 ? (flip ? flipPoints(boundaries.raceLine) : boundaries.raceLine) : null; const hasBounds = !!(boundaries?.coordSystem && flippedLeft && flippedLeft.length > 2); let minX = Infinity, maxX = -Infinity, @@ -111,6 +113,19 @@ export function drawStaticTrack(options: StaticTrackOptions): { bufferCanvas: HT if (outline) ctx.lineTo(sx, sy); ctx.stroke(); + if (raceLine) { + ctx.beginPath(); + ctx.moveTo(...toCanvas(raceLine[0].x, raceLine[0].z)); + for (let i = 1; i < raceLine.length; i++) ctx.lineTo(...toCanvas(raceLine[i].x, raceLine[i].z)); + ctx.closePath(); + ctx.globalAlpha = 1; + ctx.strokeStyle = "var(--track-racing-line)"; + ctx.lineWidth = 2.5; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.stroke(); + } + const n = displayOutline.length; const cumDist = [0]; for (let i = 1; i < n; i++) cumDist.push(cumDist[i - 1] + Math.hypot(displayOutline[i].x - displayOutline[i - 1].x, displayOutline[i].z - displayOutline[i - 1].z)); @@ -139,10 +154,11 @@ export function drawStaticTrack(options: StaticTrackOptions): { bufferCanvas: HT ctx.stroke(); }; - if (sectors && displayOutline.length > 10 && !showInputs) { + if (sectors && displayOutline.length > 10) { const bounds = [...sectors.sectorStarts, 1]; for (let si = 0; si < sectors.sectorCount; si++) drawRange(bounds[si], bounds[si + 1], SECTOR_COLOR_VARS[si % SECTOR_COLOR_VARS.length], 2.5); - } else if (segments?.length && !showInputs) { + } + if (segments?.length) { const labelCandidates: { text: string; x: number; y: number; priority: number }[] = []; const labelledNames = new Set(); for (const seg of segments) { @@ -185,7 +201,8 @@ export function drawStaticTrack(options: StaticTrackOptions): { bufferCanvas: HT ctx.fillStyle = "var(--track-label-text)"; ctx.fillText(label.text, label.x, label.y); } - } else { + } + if (!sectors && !segments?.length) { ctx.beginPath(); ctx.strokeStyle = "var(--track-edge)"; ctx.lineWidth = 2; @@ -195,7 +212,7 @@ export function drawStaticTrack(options: StaticTrackOptions): { bufferCanvas: HT ctx.stroke(); } - if (mapLabels?.length && !showInputs) { + if (mapLabels?.length) { ctx.font = "var(--font-weight-bold) var(--text-app-micro) var(--font-mono)"; ctx.textAlign = "center"; for (const label of mapLabels) { diff --git a/client/src/components/analyse/track-map/types.ts b/client/src/components/analyse/track-map/types.ts index 0671683b2..cdaabc21d 100644 --- a/client/src/components/analyse/track-map/types.ts +++ b/client/src/components/analyse/track-map/types.ts @@ -12,9 +12,7 @@ export interface SemanticValueEntry { value: unknown; } -export function semanticValues( - entries: readonly SemanticValueEntry[], -): SemanticAnalysisFrame["values"] { +export function semanticValues(entries: readonly SemanticValueEntry[]): SemanticAnalysisFrame["values"] { const values: Partial> = {}; for (const entry of entries) { if (isTelemetryVariableId(entry.semanticId)) { @@ -24,24 +22,34 @@ export function semanticValues( return values; } -export const semanticNumber = ( - frame: SemanticAnalysisFrame | undefined, - id: TelemetryVariableId, -): number | null => { +export const semanticNumber = (frame: SemanticAnalysisFrame | undefined, id: TelemetryVariableId): number | null => { const value = frame?.values[id]; return typeof value === "number" && Number.isFinite(value) ? value : null; }; -export const semanticBoolean = ( - frame: SemanticAnalysisFrame | undefined, - id: TelemetryVariableId, -): boolean => semanticNumber(frame, id) === 1; +export const semanticBoolean = (frame: SemanticAnalysisFrame | undefined, id: TelemetryVariableId): boolean => semanticNumber(frame, id) === 1; export interface Point { x: number; z: number; } +export interface TrackOverlays { + inputs: boolean; + segments: boolean; + sectors: boolean; + racingLine: boolean; +} + +export type TrackOverlayKey = keyof TrackOverlays; + +export const DEFAULT_TRACK_OVERLAYS: TrackOverlays = { + inputs: false, + segments: false, + sectors: false, + racingLine: false, +}; + export interface TrackMapLabel extends Point { text: string; } @@ -66,6 +74,7 @@ export interface TrackMapBoundaries { leftEdge: Point[]; rightEdge: Point[]; centerLine: Point[]; + raceLine?: Point[] | null; pitLane: Point[] | null; coordSystem: string; } @@ -80,6 +89,7 @@ export interface TrackMapProps { sectors: SectorBoundaries | null; segments: { type: string; name: string; startFrac: number; endFrac: number }[] | null; highlights?: TrackHighlight[] | null; + showRaceLine?: boolean; showInputs?: boolean; showTrace?: boolean; rotateWithCar: boolean; diff --git a/client/src/components/analyse/useAnalyseSelections.ts b/client/src/components/analyse/useAnalyseSelections.ts index c4e958b32..97f500290 100644 --- a/client/src/components/analyse/useAnalyseSelections.ts +++ b/client/src/components/analyse/useAnalyseSelections.ts @@ -10,8 +10,16 @@ import { useCookieState } from "../../hooks/useCookieState"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import type { AnalyseSearch } from "../../lib/game-routes"; import { mergeNameCache } from "../../lib/name-cache"; -import { type Point, type SectorBoundaries, type SemanticAnalysisFrame, semanticValues, type TrackMapBoundaries, type TrackMapLabel } from "./track-map/types"; - +import { + DEFAULT_TRACK_OVERLAYS, + semanticValues, + type Point, + type SectorBoundaries, + type SemanticAnalysisFrame, + type TrackMapBoundaries, + type TrackMapLabel, + type TrackOverlays, +} from "./track-map/types"; interface AnalyseSemanticFrame { sequence: number; observedAtMs: number; @@ -93,7 +101,7 @@ export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters("analyse-trackOverlay", "none"); + const [trackOverlays, setTrackOverlays] = useLocalStorage("analyse-trackOverlays", DEFAULT_TRACK_OVERLAYS); const [vizMode, setWheelTab] = useCookieState<"2d" | "3d">("analyse-vizMode", "2d"); const appliedVizParam = useRef(false); useEffect(() => { @@ -194,8 +202,8 @@ export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters (sessionLaps.length ? [...sessionLaps].sort((a, b) => b.lapNumber - a.lapNumber)[0] : null), [sessionLaps]); const [rotateWithCar, setRotateWithCar] = useState(false); - const [trackOverlay, setTrackOverlay] = useState<"none" | "inputs" | "segments" | "sectors">("none"); const [mapZoom, setMapZoom] = useState(1); // Live driving line for the current in-progress lap: append each new packet, @@ -127,7 +152,7 @@ export function LiveTestDashboard({ return null; }, [outlineRaw]); const { data: boundariesRaw } = useTrackBoundaries(trackOrd ?? undefined, gameId); - const boundaries = (boundariesRaw as any) ?? null; + const boundaries = (boundariesRaw as TrackMapBoundaries | null) ?? null; return (
@@ -147,10 +172,9 @@ export function LiveTestDashboard({ currentFrame={currentFrame} showTrace={false} rotateWithCar={rotateWithCar} - trackOverlay={trackOverlay} + trackOverlays={DEFAULT_TRACK_OVERLAYS} mapZoom={mapZoom} onRotateWithCarToggle={() => setRotateWithCar((r) => !r)} - onTrackOverlayCycle={() => setTrackOverlay((v) => (v === "none" ? "inputs" : v === "inputs" ? "segments" : v === "segments" ? "sectors" : "none"))} onMapZoomChange={setMapZoom} hideSteeringOverlay weatherBottomRight diff --git a/client/src/components/ui/DropdownMenu.tsx b/client/src/components/ui/DropdownMenu.tsx index d552573dd..90140b882 100644 --- a/client/src/components/ui/DropdownMenu.tsx +++ b/client/src/components/ui/DropdownMenu.tsx @@ -1,14 +1,27 @@ import { Menu } from "@base-ui/react/menu"; +import { CheckIcon } from "lucide-react"; import type { ReactElement, ReactNode } from "react"; -export interface DropdownMenuItem { +interface DropdownMenuItemBase { key: string; label: string; icon?: ReactNode; - onClick: () => void; disabled?: boolean; } +export interface DropdownMenuActionItem extends DropdownMenuItemBase { + type?: "action"; + onClick: () => void; +} + +export interface DropdownMenuCheckboxItem extends DropdownMenuItemBase { + type: "checkbox"; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +export type DropdownMenuItem = DropdownMenuActionItem | DropdownMenuCheckboxItem; + interface DropdownMenuProps { trigger: ReactNode; items: DropdownMenuItem[]; @@ -26,12 +39,29 @@ export function DropdownMenu({ trigger, items, align = "right" }: DropdownMenuPr - {items.map((item) => ( - - {item.icon} - {item.label} - - ))} + + {items.map((item) => + item.type === "checkbox" ? ( + + + + + {item.label} + + ) : ( + + {item.icon} + {item.label} + + ), + )} + diff --git a/client/src/components/wireframe/CarScene.tsx b/client/src/components/wireframe/CarScene.tsx index 202a3f081..bb8bec4e3 100644 --- a/client/src/components/wireframe/CarScene.tsx +++ b/client/src/components/wireframe/CarScene.tsx @@ -17,7 +17,7 @@ import { DimensionLines } from "./DimensionLines"; import { InputOverlay } from "./InputOverlay"; import { SuspensionSpring } from "./SuspensionSpring"; import { TireTrails } from "./TireTrails"; -import { TrackBoundaryEdges, TrackOutline } from "./TrackElements"; +import { TrackBoundaryEdges, TrackLine } from "./TrackElements"; import { Wheel } from "./Wheel"; // Load-dot geometry: direction comes from the baseline-subtracted weighted @@ -78,7 +78,7 @@ export function CarScene({ telemetry: SemanticAnalysisFrame[]; cursorIdx: number; outline: { x: number; z: number }[] | null; - boundaries: { leftEdge: { x: number; z: number }[]; rightEdge: { x: number; z: number }[] } | null; + boundaries: { leftEdge: { x: number; z: number }[]; rightEdge: { x: number; z: number }[]; raceLine?: { x: number; z: number }[] | null } | null; toggles: ViewToggles; viewPreset: ViewPreset; carModel: CarModelEnrichment & { hasModel: boolean }; @@ -459,7 +459,12 @@ export function CarScene({ {/* Track outline (center line) */} - {toggles.track && outline && } + {toggles.track && outline && } + + {/* Game-provided reference racing line */} + {toggles.racingLine && boundaries?.raceLine && boundaries.raceLine.length > 1 && ( + + )} {/* Track boundary edges (walls) */} {toggles.track && boundaries && } diff --git a/client/src/components/wireframe/ToggleButton.tsx b/client/src/components/wireframe/ToggleButton.tsx index a510cfd95..6daa2c9ad 100644 --- a/client/src/components/wireframe/ToggleButton.tsx +++ b/client/src/components/wireframe/ToggleButton.tsx @@ -4,6 +4,7 @@ export function ToggleButton({ label, active, onClick }: { label: string; active return (