racecraft/src/views/RacingLineView.vue
Jan-Peter v. Hunnius 02d3ea0515 feat: racing line view, canvas zoom/pan, track build UI
- Add RacingLineView.vue: canvas track map with speed heatmap and
  per-lap color modes, lap toggle checkboxes, lap stats table.
- Add useCanvasZoom composable: wheel zoom toward cursor, drag pan,
  reset button with zoom indicator.
- Apply zoom/pan to both RacingLineView and TrackDetailView.
- Add RacingLineAnalysis types + isRacingLineAnalysis type guard.
- Add route /analysis/racing-line/:id.
- Track build UI: game dropdown + track name input, proper error
  display (extracts data.error from axios response body).
- Add .form-input CSS utility class.
- Racing line quick stats in SessionDetailView.
- Fix missing resize listener in RacingLineView.
- Fix indentation on grip stat card.
2026-07-04 11:17:13 +02:00

420 lines
13 KiB
Vue

<!-- 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 <https://www.gnu.org/licenses/>. -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { RacingLineAnalysis } from '@/types'
import { isRacingLineAnalysis } from '@/utils/analysis'
import { useCanvasZoom } from '@/composables/useCanvasZoom'
import * as api from '@/api/client'
const route = useRoute()
const router = useRouter()
const sessionId = computed(() => route.params.id as string)
const analysis = ref<RacingLineAnalysis | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const canvas = ref<HTMLCanvasElement | null>(null)
const colorMode = ref<'speed' | 'lap'>('speed')
const visibleLaps = ref<Set<number>>(new Set())
const lapColors = ['#4f8ff7', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#22d3ee', '#fb923c', '#ec4899']
const { zoom, onWheel, onMouseDown, onMouseMove, onMouseUp, reset: resetZoom, applyTransform } = useCanvasZoom(canvas, draw)
onMounted(async () => {
try {
const data = await api.getAnalysis(sessionId.value)
if (isRacingLineAnalysis(data)) {
analysis.value = data
visibleLaps.value = new Set(data.laps.map((_, i) => i))
} else {
error.value = 'Analysis data not available or wrong type'
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load analysis'
} finally {
loading.value = false
}
await nextTick()
resizeCanvas()
draw()
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
function onResize() {
resizeCanvas()
draw()
}
function resizeCanvas() {
if (!canvas.value) return
const container = canvas.value.parentElement
if (container) {
canvas.value.width = container.clientWidth
canvas.value.height = Math.max(500, container.clientWidth * 0.6)
}
}
watch([analysis, colorMode, visibleLaps], () => draw(), { deep: true })
function toggleLap(idx: number) {
if (visibleLaps.value.has(idx)) {
visibleLaps.value.delete(idx)
} else {
visibleLaps.value.add(idx)
}
visibleLaps.value = new Set(visibleLaps.value)
}
function draw() {
if (!canvas.value || !analysis.value) return
const ctx = canvas.value.getContext('2d')
if (!ctx) return
const laps = analysis.value.laps
const width = canvas.value.width
const height = canvas.value.height
const padding = 50
const drawWidth = width - padding * 2
const drawHeight = height - padding * 2
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++) {
const x = padding + (drawWidth / 10) * i
const y = padding + (drawHeight / 10) * i
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, height - padding)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(width - padding, y)
ctx.stroke()
}
const visiblePoints = laps
.filter((_, i) => visibleLaps.value.has(i))
.flatMap((lap) => lap.points)
if (visiblePoints.length === 0) return
const minX = visiblePoints.reduce((m, p) => p.x < m ? p.x : m, Infinity)
const maxX = visiblePoints.reduce((m, p) => p.x > m ? p.x : m, -Infinity)
const minZ = visiblePoints.reduce((m, p) => p.z < m ? p.z : m, Infinity)
const maxZ = visiblePoints.reduce((m, p) => p.z > m ? p.z : m, -Infinity)
const rangeX = maxX - minX || 1
const rangeZ = maxZ - minZ || 1
const scale = Math.min(drawWidth / rangeX, drawHeight / rangeZ)
const offsetX = padding + (drawWidth - rangeX * scale) / 2
const offsetY = padding + (drawHeight - rangeZ * scale) / 2
function toCanvas(x: number, z: number): [number, number] {
return [
offsetX + (x - minX) * scale,
offsetY + (maxZ - z) * scale,
]
}
const allSpeeds = visiblePoints.map((p) => p.speed_kmh)
const minSpeed = Math.min(...allSpeeds)
const maxSpeed = Math.max(...allSpeeds)
function speedColor(speed: number): string {
const t = maxSpeed > minSpeed ? (speed - minSpeed) / (maxSpeed - minSpeed) : 0.5
if (t < 0.5) {
const r = Math.round(79 + (251 - 79) * (t * 2))
const g = Math.round(143 + (191 - 143) * (t * 2))
const b = Math.round(247 + (36 - 247) * (t * 2))
return `rgb(${r}, ${g}, ${b})`
} else {
const t2 = (t - 0.5) * 2
const r = Math.round(251 + (248 - 251) * t2)
const g = Math.round(191 + (113 - 191) * t2)
const b = Math.round(36 + (113 - 36) * t2)
return `rgb(${r}, ${g}, ${b})`
}
}
laps.forEach((lap, lapIdx) => {
if (!visibleLaps.value.has(lapIdx)) return
const pts = lap.points
if (pts.length < 2) return
if (colorMode.value === 'speed') {
ctx.lineWidth = 2.5
for (let i = 1; i < pts.length; i++) {
const [sx, sy] = toCanvas(pts[i - 1].x, pts[i - 1].z)
const [ex, ey] = toCanvas(pts[i].x, pts[i].z)
ctx.strokeStyle = speedColor((pts[i].speed_kmh + pts[i - 1].speed_kmh) / 2)
ctx.beginPath()
ctx.moveTo(sx, sy)
ctx.lineTo(ex, ey)
ctx.stroke()
}
} else {
ctx.strokeStyle = lapColors[lapIdx % lapColors.length]
ctx.lineWidth = 2.5
ctx.globalAlpha = 0.85
ctx.beginPath()
const [sx, sy] = toCanvas(pts[0].x, pts[0].z)
ctx.moveTo(sx, sy)
for (let i = 1; i < pts.length; i++) {
const [px, py] = toCanvas(pts[i].x, pts[i].z)
ctx.lineTo(px, py)
}
ctx.stroke()
ctx.globalAlpha = 1
}
const [startX, startY] = toCanvas(pts[0].x, pts[0].z)
ctx.fillStyle = lapColors[lapIdx % lapColors.length]
ctx.beginPath()
ctx.arc(startX, startY, 5, 0, Math.PI * 2)
ctx.fill()
})
ctx.restore()
ctx.fillStyle = '#8b8fa3'
ctx.font = '11px monospace'
if (colorMode.value === 'speed') {
const legendX = width - 160
const legendY = 60
const barWidth = 120
const barHeight = 10
const grad = ctx.createLinearGradient(legendX, 0, legendX + barWidth, 0)
grad.addColorStop(0, speedColor(minSpeed))
grad.addColorStop(0.5, speedColor((minSpeed + maxSpeed) / 2))
grad.addColorStop(1, speedColor(maxSpeed))
ctx.fillStyle = grad
ctx.fillRect(legendX, legendY, barWidth, barHeight)
ctx.strokeStyle = '#3a3e4f'
ctx.strokeRect(legendX, legendY, barWidth, barHeight)
ctx.fillStyle = '#8b8fa3'
ctx.fillText(`${Math.round(minSpeed)} km/h`, legendX, legendY + 25)
ctx.fillText(`${Math.round(maxSpeed)} km/h`, legendX + barWidth - 50, legendY + 25)
}
}
function formatLapTime(ms: number): string {
if (ms <= 0) return '--'
const totalSec = ms / 1000
const min = Math.floor(totalSec / 60)
const sec = totalSec - min * 60
return `${min}:${sec.toFixed(3).padStart(6, '0')}`
}
</script>
<template>
<div class="racing-line-view">
<div class="flex items-center gap-2 mb-3">
<button class="btn btn-secondary btn-sm" @click="router.back()">← Back</button>
<h2 class="page-title">Racing Line Analysis</h2>
</div>
<div v-if="loading" class="card text-secondary">Loading analysis...</div>
<div v-if="error" class="card text-error">{{ error }}</div>
<template v-if="analysis">
<!-- Summary Stats -->
<div class="stat-grid mb-3">
<div class="stat-card">
<span class="stat-label">Laps Analyzed</span>
<span class="stat-value mono">{{ analysis.total_laps }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Frames Analyzed</span>
<span class="stat-value mono">{{ analysis.total_frames_analyzed.toLocaleString() }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Position Mode</span>
<span class="stat-value mono" style="font-size: 1rem">{{ analysis.position_mode }}</span>
</div>
</div>
<!-- Canvas Track Map -->
<div class="card mb-3">
<div class="card-header">
<h2>Racing Line</h2>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="colorMode === 'speed' ? 'btn-primary' : 'btn-secondary'"
@click="colorMode = 'speed'"
>Speed Heatmap</button>
<button
class="btn btn-sm"
:class="colorMode === 'lap' ? 'btn-primary' : 'btn-secondary'"
@click="colorMode = 'lap'"
>Per Lap</button>
<button class="btn btn-sm btn-secondary" @click="resetZoom">
Reset View ({{ zoom.toFixed(1) }}x)
</button>
</div>
</div>
<div class="canvas-container">
<canvas
ref="canvas"
style="cursor: grab"
@wheel="onWheel"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
/>
</div>
</div>
<!-- Lap List -->
<div class="card">
<div class="card-header">
<h2>Laps</h2>
</div>
<table class="data-table">
<thead>
<tr>
<th></th>
<th>Lap</th>
<th>Run Label</th>
<th>Lap Time</th>
<th>Length</th>
<th>Avg Speed</th>
<th>Max Speed</th>
<th>Points</th>
</tr>
</thead>
<tbody>
<tr v-for="(lap, idx) in analysis.laps" :key="idx">
<td>
<input
type="checkbox"
:checked="visibleLaps.has(idx)"
@change="toggleLap(idx)"
>
</td>
<td class="mono">
<span
v-if="colorMode === 'lap'"
class="color-dot"
:style="{ background: lapColors[idx % lapColors.length] }"
></span>
{{ lap.lap_index + 1 }}
</td>
<td>{{ lap.label || '—' }}</td>
<td class="mono">{{ formatLapTime(lap.lap_time_ms) }}</td>
<td class="mono">{{ lap.length_m > 0 ? Math.round(lap.length_m) + ' m' : '—' }}</td>
<td class="mono">{{ lap.avg_speed_kmh > 0 ? Math.round(lap.avg_speed_kmh) + ' km/h' : '—' }}</td>
<td class="mono">{{ lap.max_speed_kmh > 0 ? Math.round(lap.max_speed_kmh) + ' km/h' : '—' }}</td>
<td class="mono">{{ lap.point_count }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
<style scoped>
.page-title {
font-size: 1.5rem;
margin: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.75rem;
background: var(--color-background);
border-radius: var(--radius-sm);
}
.stat-label {
font-size: 0.7rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.canvas-container {
width: 100%;
}
canvas {
display: block;
width: 100%;
border-radius: var(--radius-sm);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.data-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.data-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
</style>