- 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.
420 lines
13 KiB
Vue
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>
|