diff --git a/src/assets/main.css b/src/assets/main.css index 0a5bff9..2578c3d 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -71,6 +71,18 @@ input, select, textarea { } /* Utility classes */ +.form-input { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + color: var(--color-text); +} + +.form-input:focus { + border-color: var(--color-primary); +} + .card { background: var(--color-surface); border: 1px solid var(--color-border); diff --git a/src/composables/useCanvasZoom.ts b/src/composables/useCanvasZoom.ts new file mode 100644 index 0000000..8adcf3a --- /dev/null +++ b/src/composables/useCanvasZoom.ts @@ -0,0 +1,99 @@ +// racecraft — Vue.js web client for sim racing telemetry analysis +// Copyright (C) 2026 Jan-Peter v. Hunnius +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { ref, type Ref } from 'vue' + +export interface ZoomState { + zoom: number + panX: number + panY: number +} + +export function useCanvasZoom(canvasRef: Ref, redraw: () => void) { + const zoom = ref(1) + const panX = ref(0) + const panY = ref(0) + const isDragging = ref(false) + const lastMouseX = ref(0) + const lastMouseY = ref(0) + + function onWheel(e: WheelEvent) { + e.preventDefault() + if (!canvasRef.value) return + + const rect = canvasRef.value.getBoundingClientRect() + const mouseX = e.clientX - rect.left + const mouseY = e.clientY - rect.top + + const oldZoom = zoom.value + const delta = e.deltaY > 0 ? 0.9 : 1.1 + zoom.value = Math.max(0.5, Math.min(20, zoom.value * delta)) + + // Zoom toward mouse position + const scale = zoom.value / oldZoom + panX.value = mouseX - (mouseX - panX.value) * scale + panY.value = mouseY - (mouseY - panY.value) * scale + + redraw() + } + + function onMouseDown(e: MouseEvent) { + isDragging.value = true + lastMouseX.value = e.clientX + lastMouseY.value = e.clientY + if (canvasRef.value) { + canvasRef.value.style.cursor = 'grabbing' + } + } + + function onMouseMove(e: MouseEvent) { + if (!isDragging.value) return + panX.value += e.clientX - lastMouseX.value + panY.value += e.clientY - lastMouseY.value + lastMouseX.value = e.clientX + lastMouseY.value = e.clientY + redraw() + } + + function onMouseUp() { + isDragging.value = false + if (canvasRef.value) { + canvasRef.value.style.cursor = 'grab' + } + } + + function reset() { + zoom.value = 1 + panX.value = 0 + panY.value = 0 + redraw() + } + + function applyTransform(ctx: CanvasRenderingContext2D) { + ctx.translate(panX.value, panY.value) + ctx.scale(zoom.value, zoom.value) + } + + return { + zoom, + isDragging, + onWheel, + onMouseDown, + onMouseMove, + onMouseUp, + reset, + applyTransform, + } +} diff --git a/src/router/index.ts b/src/router/index.ts index d937860..ed4fbd1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -51,6 +51,12 @@ const router = createRouter({ component: () => import('@/views/GripAnalysisView.vue'), props: true, }, + { + path: '/analysis/racing-line/:id', + name: 'racing-line', + component: () => import('@/views/RacingLineView.vue'), + props: true, + }, { path: '/tracks', name: 'tracks', diff --git a/src/types/index.ts b/src/types/index.ts index 5103969..c8a4b13 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -290,7 +290,35 @@ export interface BuildTrackModelRequest { export type TrackMappingAnalysis = TrackMapAnalysis -export type AnalysisResult = ShiftPointAnalysis | CarGripAnalysis | TrackMapAnalysis | NotImplementedResponse +// ── Racing Line Analysis ── + +export interface RacingLinePoint { + x: number + y: number + z: number + speed_kmh: number + spline: number +} + +export interface RacingLap { + label: string + lap_index: number + point_count: number + points: RacingLinePoint[] + lap_time_ms: number + length_m: number + avg_speed_kmh: number + max_speed_kmh: number +} + +export interface RacingLineAnalysis { + laps: RacingLap[] + position_mode: 'Spline' | 'Coordinate' + total_laps: number + total_frames_analyzed: number +} + +export type AnalysisResult = ShiftPointAnalysis | CarGripAnalysis | TrackMapAnalysis | RacingLineAnalysis | NotImplementedResponse // ── Characteristics (aggregated across sessions) ── diff --git a/src/utils/analysis.ts b/src/utils/analysis.ts index 6c16ff7..349524e 100644 --- a/src/utils/analysis.ts +++ b/src/utils/analysis.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { ShiftPointAnalysis, CarGripAnalysis, TrackMapAnalysis } from '@/types' +import type { ShiftPointAnalysis, CarGripAnalysis, TrackMapAnalysis, RacingLineAnalysis } from '@/types' /** * Runtime type guard: checks if an unknown value is a ShiftPointAnalysis. @@ -45,3 +45,11 @@ export function isTrackMapAnalysis(data: unknown): data is TrackMapAnalysis { export function isNotImplemented(data: unknown): data is { status: 'not_implemented'; use_case: string } { return typeof data === 'object' && data !== null && 'status' in data && (data as { status: string }).status === 'not_implemented' } + +/** + * Runtime type guard: checks if an unknown value is a RacingLineAnalysis. + * Uses the 'laps' discriminator field. + */ +export function isRacingLineAnalysis(data: unknown): data is RacingLineAnalysis { + return typeof data === 'object' && data !== null && 'laps' in data && 'position_mode' in data +} diff --git a/src/views/RacingLineView.vue b/src/views/RacingLineView.vue new file mode 100644 index 0000000..1088734 --- /dev/null +++ b/src/views/RacingLineView.vue @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/SessionDetailView.vue b/src/views/SessionDetailView.vue index 576960d..60b8bba 100644 --- a/src/views/SessionDetailView.vue +++ b/src/views/SessionDetailView.vue @@ -17,8 +17,8 @@ import { ref, onMounted, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useSessionsStore } from '@/stores/sessions' -import type { ShiftPointAnalysis, CarGripAnalysis, TrackMapAnalysis } from '@/types' -import { isShiftPointAnalysis, isCarGripAnalysis, isTrackMapAnalysis } from '@/utils/analysis' +import type { ShiftPointAnalysis, CarGripAnalysis, TrackMapAnalysis, RacingLineAnalysis } from '@/types' +import { isShiftPointAnalysis, isCarGripAnalysis, isTrackMapAnalysis, isRacingLineAnalysis } from '@/utils/analysis' import * as api from '@/api/client' const props = defineProps<{ @@ -79,6 +79,14 @@ const gripAnalysis = computed((): CarGripAnalysis | null => { return null }) +const racingLineAnalysis = computed((): RacingLineAnalysis | null => { + if (!analysisData.value) return null + if (isRacingLineAnalysis(analysisData.value)) { + return analysisData.value + } + return null +}) + onMounted(async () => { await store.fetchSession(sessionId.value) }) @@ -126,9 +134,19 @@ function viewAnalysis() { router.push({ name: 'shift-points', params: { id: session.value.id } }) } else if (session.value.use_case === 'car_grip') { router.push({ name: 'grip-analysis', params: { id: session.value.id } }) + } else if (session.value.use_case === 'racing_line') { + router.push({ name: 'racing-line', params: { id: session.value.id } }) } } +function formatLapTimeShort(ms: number): string { + if (!ms || ms <= 0 || !isFinite(ms)) return '--' + const totalSec = ms / 1000 + const min = Math.floor(totalSec / 60) + const sec = totalSec - min * 60 + return `${min}:${sec.toFixed(3).padStart(6, '0')}` +} + async function handleDelete() { if (confirm('Delete this session?')) { await store.deleteSession(sessionId.value) @@ -149,6 +167,15 @@ async function handleReopen() { const buildingTrack = ref(false) const buildTrackError = ref(null) const buildTrackSuccess = ref(false) +const buildGame = ref('assetto_corsa') +const buildTrackName = ref('') + +const gameOptions = [ + { value: 'assetto_corsa', label: 'Assetto Corsa' }, + { value: 'assetto_corsa_competizione', label: 'Assetto Corsa Competizione' }, + { value: 'project_cars_1', label: 'Project CARS 1' }, + { value: 'project_cars_2', label: 'Project CARS 2' }, +] const isTrackMapping = computed(() => session.value?.use_case === 'track_mapping') @@ -168,10 +195,16 @@ async function handleBuildTrack() { buildTrackSuccess.value = false try { await store.reAnalyze(sessionId.value) - await api.buildTrackModel({ session_id: sessionId.value }) + await api.buildTrackModel({ + session_id: sessionId.value, + game: buildGame.value, + track_name: buildTrackName.value || undefined, + }) buildTrackSuccess.value = true } catch (e: unknown) { - buildTrackError.value = e instanceof Error ? e.message : 'Failed to build track model' + const axiosErr = e as { response?: { data?: { error?: string } } } + buildTrackError.value = axiosErr.response?.data?.error + ?? (e instanceof Error ? e.message : 'Failed to build track model') } finally { buildingTrack.value = false } @@ -384,8 +417,32 @@ async function handleBuildTrack() { +
+
+ Laps + {{ racingLineAnalysis.total_laps }} +
+
+ Best Lap + + {{ racingLineAnalysis.laps.length > 0 + ? formatLapTimeShort(Math.min(...racingLineAnalysis.laps.filter(l => l.lap_time_ms > 0).map(l => l.lap_time_ms))) + : '--' }} + +
+
+ Max Speed + {{ Math.round(Math.max(...racingLineAnalysis.laps.map(l => l.max_speed_kmh), 0)) }} + km/h +
+
+ Frames + {{ racingLineAnalysis.total_frames_analyzed.toLocaleString() }} +
+
+
-

- Both boundary runs (left + right) are recorded. Build the persistent track model to enable corner analysis and sector timing. + Both boundary runs (left + right) are recorded. Name the track and build the persistent track model to enable corner analysis and sector timing.

- +
+ + + +
Track model built successfully. View in Tracks →
diff --git a/src/views/TrackDetailView.vue b/src/views/TrackDetailView.vue index 24042a9..6c985e5 100644 --- a/src/views/TrackDetailView.vue +++ b/src/views/TrackDetailView.vue @@ -17,6 +17,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useTracksStore } from '@/stores/tracks' +import { useCanvasZoom } from '@/composables/useCanvasZoom' import type { Corner } from '@/types' let resizeRafId: number | null = null @@ -33,6 +34,8 @@ const showCurvature = ref(true) const showCorners = ref(true) const showSectors = ref(true) +const { zoom, onWheel, onMouseDown, onMouseMove, onMouseUp, reset: resetZoom, applyTransform } = useCanvasZoom(canvas, drawTrack) + onMounted(async () => { await store.fetchTrack(game.value, trackName.value) }) @@ -58,6 +61,9 @@ function drawTrack() { ctx.fillStyle = '#0f1117' ctx.fillRect(0, 0, width, height) + ctx.save() + applyTransform(ctx) + ctx.strokeStyle = '#1a1d27' ctx.lineWidth = 1 for (let i = 0; i <= 10; i++) { @@ -212,6 +218,8 @@ function drawTrack() { ctx.arc(startX, startY, 6, 0, Math.PI * 2) ctx.stroke() } + + ctx.restore() } function handleResize() { @@ -328,6 +336,9 @@ watch([showCurvature, showCorners, showSectors], () => { Sectors +
@@ -350,7 +361,15 @@ watch([showCurvature, showCorners, showSectors], () => {
- +