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:
Jan-Peter von Hunnius 2026-07-04 11:17:13 +02:00
parent 2228a71a5f
commit 02d3ea0515
8 changed files with 677 additions and 16 deletions

View file

@ -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);

View 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,
}
}

View file

@ -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',

View file

@ -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) ──

View file

@ -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
}

View 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>

View file

@ -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>

View file

@ -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>