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.
This commit is contained in:
parent
2228a71a5f
commit
02d3ea0515
8 changed files with 677 additions and 16 deletions
420
src/views/RacingLineView.vue
Normal file
420
src/views/RacingLineView.vue
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
<!-- 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue