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
|
|
@ -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);
|
||||
|
|
|
|||
99
src/composables/useCanvasZoom.ts
Normal file
99
src/composables/useCanvasZoom.ts
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
export interface ZoomState {
|
||||
zoom: number
|
||||
panX: number
|
||||
panY: number
|
||||
}
|
||||
|
||||
export function useCanvasZoom(canvasRef: Ref<HTMLCanvasElement | null>, 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// 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/>.
|
||||
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
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -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<string | null>(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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="racingLineAnalysis" class="grid-4 mb-2">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Laps</span>
|
||||
<span class="stat-value mono">{{ racingLineAnalysis.total_laps }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Best Lap</span>
|
||||
<span class="stat-value mono" style="font-size: 1.1rem">
|
||||
{{ racingLineAnalysis.laps.length > 0
|
||||
? formatLapTimeShort(Math.min(...racingLineAnalysis.laps.filter(l => l.lap_time_ms > 0).map(l => l.lap_time_ms)))
|
||||
: '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Max Speed</span>
|
||||
<span class="stat-value mono">{{ Math.round(Math.max(...racingLineAnalysis.laps.map(l => l.max_speed_kmh), 0)) }}</span>
|
||||
<span class="stat-sub">km/h</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Frames</span>
|
||||
<span class="stat-value mono">{{ racingLineAnalysis.total_frames_analyzed.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button v-if="session.use_case === 'shift_points' || session.use_case === 'car_grip'" class="btn btn-primary" @click="viewAnalysis">
|
||||
<button v-if="session.use_case === 'shift_points' || session.use_case === 'car_grip' || session.use_case === 'racing_line'" class="btn btn-primary" @click="viewAnalysis">
|
||||
📊 View Full Analysis
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="handleReAnalyze">
|
||||
|
|
@ -412,15 +469,27 @@ async function handleBuildTrack() {
|
|||
</div>
|
||||
<div v-if="canBuildTrack">
|
||||
<p class="text-secondary mb-2" style="font-size: 0.85rem">
|
||||
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.
|
||||
</p>
|
||||
<div class="flex gap-2 mb-2 flex-wrap">
|
||||
<select v-model="buildGame" class="form-input" style="width: auto">
|
||||
<option v-for="opt in gameOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="buildTrackName"
|
||||
class="form-input"
|
||||
style="flex: 1; min-width: 180px"
|
||||
placeholder="Track name (e.g. ks_nurburgring)"
|
||||
:disabled="buildingTrack"
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="buildingTrack"
|
||||
:disabled="buildingTrack || !buildTrackName.trim()"
|
||||
@click="handleBuildTrack"
|
||||
>
|
||||
{{ buildingTrack ? 'Building...' : 'Build Track Model' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="buildTrackSuccess" class="text-success mt-2" style="font-size: 0.85rem">
|
||||
Track model built successfully. <RouterLink to="/tracks">View in Tracks →</RouterLink>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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], () => {
|
|||
<input type="checkbox" v-model="showSectors" />
|
||||
<span>Sectors</span>
|
||||
</label>
|
||||
<button class="btn btn-secondary btn-sm" @click="resetZoom">
|
||||
Reset View ({{ zoom.toFixed(1) }}x)
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" @click="handleDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -350,7 +361,15 @@ watch([showCurvature, showCorners, showSectors], () => {
|
|||
</span>
|
||||
</div>
|
||||
<div class="canvas-container">
|
||||
<canvas ref="canvas" />
|
||||
<canvas
|
||||
ref="canvas"
|
||||
style="cursor: grab"
|
||||
@wheel="onWheel"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue