feat: initial Racecraft web client v0.1.0
Vue 3 + TypeScript + Vite web client for rusty-telemetry. - Dashboard with system status, live feeds, quick actions - Live telemetry view with real-time gauges (speed/RPM/gear) - Session management (create, run, complete, analyze) - Shift point analysis with Chart.js visualizations - Track map with Canvas 2D boundary rendering - Typed API client for all 19 REST endpoints - Dark theme, network-accessible via Vite 0.0.0.0 bind
This commit is contained in:
commit
cf9af73281
27 changed files with 5123 additions and 0 deletions
279
src/views/DashboardView.vue
Normal file
279
src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useTelemetryStore } from '@/stores/telemetry'
|
||||
import { useSessionsStore } from '@/stores/sessions'
|
||||
import FeedStatus from '@/components/FeedStatus.vue'
|
||||
|
||||
const telemetry = useTelemetryStore()
|
||||
const sessions = useSessionsStore()
|
||||
const apiPort = ref(8080)
|
||||
|
||||
onMounted(() => {
|
||||
telemetry.startPolling(1000)
|
||||
sessions.fetchSessions()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
telemetry.stopPolling()
|
||||
})
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function useCaseLabel(uc: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
shift_points: 'Shift Points',
|
||||
racing_line: 'Racing Line',
|
||||
braking_analysis: 'Braking Analysis',
|
||||
lap_times: 'Lap Times',
|
||||
sector_analysis: 'Sector Analysis',
|
||||
track_map: 'Track Map',
|
||||
general: 'General',
|
||||
}
|
||||
return labels[uc] ?? uc
|
||||
}
|
||||
|
||||
const simStatusClass = computed(() => {
|
||||
if (!telemetry.connected) return 'status-warn'
|
||||
if (telemetry.anyFeedAlive) return 'status-ok'
|
||||
if (telemetry.anyFeedStale) return 'status-warn'
|
||||
return 'status-idle'
|
||||
})
|
||||
|
||||
const simStatusIcon = computed(() => {
|
||||
if (!telemetry.connected) return '○'
|
||||
if (telemetry.anyFeedAlive) return '●'
|
||||
if (telemetry.anyFeedStale) return '◐'
|
||||
return '○'
|
||||
})
|
||||
|
||||
const aliveCount = computed(() => telemetry.feeds.filter((f) => f.health === 'alive').length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h2 class="page-title">Dashboard</h2>
|
||||
|
||||
<!-- System Status -->
|
||||
<section class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>System Status</h2>
|
||||
<span :class="['badge', telemetry.overallStatusClass]">
|
||||
{{ telemetry.overallStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<!-- API Server -->
|
||||
<div class="status-item">
|
||||
<span class="status-icon" :class="telemetry.connected ? 'status-ok' : 'status-err'">
|
||||
{{ telemetry.connected ? '●' : '✕' }}
|
||||
</span>
|
||||
<div class="status-detail">
|
||||
<span class="status-name">API Server</span>
|
||||
<span class="status-info">
|
||||
<template v-if="telemetry.connected">
|
||||
<span class="mono">localhost:{{ apiPort }}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary">{{ telemetry.feeds.length }} feed(s) registered</span>
|
||||
</template>
|
||||
<span v-else class="text-error">{{ telemetry.error || 'Not reachable' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sim Connection -->
|
||||
<div class="status-item">
|
||||
<span class="status-icon" :class="simStatusClass">
|
||||
{{ simStatusIcon }}
|
||||
</span>
|
||||
<div class="status-detail">
|
||||
<span class="status-name">Sim Connection</span>
|
||||
<span class="status-info">
|
||||
<template v-if="!telemetry.connected">
|
||||
<span class="text-muted">Waiting for API server</span>
|
||||
</template>
|
||||
<template v-else-if="telemetry.anyFeedAlive">
|
||||
<span class="text-success">{{ aliveCount }} feed(s) receiving data</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary">{{ telemetry.totalPackets.toLocaleString() }} packets total</span>
|
||||
</template>
|
||||
<template v-else-if="telemetry.anyFeedStale">
|
||||
<span class="text-warning">Data stream stalled</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary">{{ telemetry.totalPackets.toLocaleString() }} packets received</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-muted">No sim data detected</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary">Start a sim session to begin</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Recording -->
|
||||
<div class="status-item">
|
||||
<span class="status-icon" :class="telemetry.activeRecording ? 'status-ok' : 'status-warn'">
|
||||
{{ telemetry.activeRecording ? '●' : '○' }}
|
||||
</span>
|
||||
<div class="status-detail">
|
||||
<span class="status-name">Recording</span>
|
||||
<span class="status-info">
|
||||
<template v-if="telemetry.activeRecording">
|
||||
<span class="badge badge-success" style="font-size: 0.7rem">ACTIVE</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary mono" style="font-size: 0.75rem">{{ telemetry.activeRecording.substring(0, 8) }}…</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-muted">No active recording</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="telemetry.lastUpdate && telemetry.connected" class="update-info">
|
||||
Last update: {{ telemetry.lastUpdate.toLocaleTimeString() }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live Feeds -->
|
||||
<section class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>Live Feeds</h2>
|
||||
<span class="text-secondary">{{ telemetry.feeds.length }} feed(s)</span>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
<FeedStatus
|
||||
v-for="feed in telemetry.feeds"
|
||||
:key="feed.port"
|
||||
:feed="feed"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="telemetry.feeds.length === 0" class="text-secondary text-muted mt-2">
|
||||
No feeds detected. Start a sim session to see live data.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<section class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>Quick Actions</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<RouterLink to="/live" class="btn btn-primary">📡 Live Telemetry</RouterLink>
|
||||
<RouterLink to="/sessions" class="btn btn-secondary">📊 Sessions</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recent Sessions -->
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Recent Sessions</h2>
|
||||
<RouterLink to="/sessions" class="text-secondary" style="font-size: 0.85rem">View all →</RouterLink>
|
||||
</div>
|
||||
<div v-if="sessions.sessions.length === 0" class="text-secondary text-muted">
|
||||
No sessions yet. Create one to start analyzing your driving.
|
||||
</div>
|
||||
<div v-else class="session-list">
|
||||
<div
|
||||
v-for="session in sessions.sessions.slice(0, 5)"
|
||||
:key="session.id"
|
||||
class="session-item"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['badge', session.status === 'active' ? 'badge-primary' : 'badge-muted']">
|
||||
{{ session.status }}
|
||||
</span>
|
||||
<span class="text-secondary">{{ useCaseLabel(session.use_case) }}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span>{{ session.label }}</span>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size: 0.8rem">{{ formatTime(session.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.status-item + .status-item {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-ok { color: var(--color-success); }
|
||||
.status-warn { color: var(--color-warning); }
|
||||
.status-err { color: var(--color-error); }
|
||||
.status-idle { color: var(--color-text-muted); }
|
||||
|
||||
.status-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.update-info {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
304
src/views/LiveView.vue
Normal file
304
src/views/LiveView.vue
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useTelemetryStore } from '@/stores/telemetry'
|
||||
import FeedStatus from '@/components/FeedStatus.vue'
|
||||
|
||||
const telemetry = useTelemetryStore()
|
||||
|
||||
onMounted(() => {
|
||||
telemetry.startPolling(250)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
telemetry.stopPolling()
|
||||
})
|
||||
|
||||
const aliveCount = computed(() => telemetry.feeds.filter((f) => f.health === 'alive').length)
|
||||
|
||||
const primaryFeed = computed(() => {
|
||||
const alive = telemetry.feeds.find((f) => f.health === 'alive')
|
||||
return alive ?? telemetry.feeds[0] ?? null
|
||||
})
|
||||
|
||||
function formatSpeed(kmh: number | null): string {
|
||||
if (kmh === null) return '---'
|
||||
return Math.round(kmh).toString()
|
||||
}
|
||||
|
||||
function formatRpm(rpm: number | null): string {
|
||||
if (rpm === null) return '-----'
|
||||
return Math.round(rpm).toString()
|
||||
}
|
||||
|
||||
function throttleWidth(val: number | null): string {
|
||||
if (val === null) return '0%'
|
||||
return `${Math.round(val * 100)}%`
|
||||
}
|
||||
|
||||
function brakeWidth(val: number | null): string {
|
||||
if (val === null) return '0%'
|
||||
return `${Math.round(val * 100)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="live-view">
|
||||
<h2 class="page-title">Live Telemetry</h2>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div :class="['status-bar', telemetry.connected ? '' : 'status-bar-error']">
|
||||
<div class="status-bar-left">
|
||||
<span :class="['badge', telemetry.overallStatusClass]">
|
||||
{{ telemetry.overallStatusLabel }}
|
||||
</span>
|
||||
<template v-if="telemetry.connected">
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary" style="font-size: 0.8rem">
|
||||
{{ aliveCount }}/{{ telemetry.feeds.length }} feeds active
|
||||
</span>
|
||||
<span v-if="telemetry.activeRecording" class="text-muted">·</span>
|
||||
<span v-if="telemetry.activeRecording" class="text-secondary" style="font-size: 0.8rem">
|
||||
Recording
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<span v-if="telemetry.lastUpdate" class="text-muted" style="font-size: 0.75rem">
|
||||
{{ telemetry.lastUpdate.toLocaleTimeString() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Primary Display -->
|
||||
<div v-if="primaryFeed" class="card primary-display mb-3">
|
||||
<div class="primary-grid">
|
||||
<!-- Speed -->
|
||||
<div class="gauge gauge-speed">
|
||||
<div class="gauge-value mono">{{ formatSpeed(primaryFeed.speed_kmh) }}</div>
|
||||
<div class="gauge-unit">km/h</div>
|
||||
</div>
|
||||
|
||||
<!-- RPM -->
|
||||
<div class="gauge gauge-rpm">
|
||||
<div class="gauge-value mono">{{ formatRpm(primaryFeed.engine_rpm) }}</div>
|
||||
<div class="gauge-unit">RPM</div>
|
||||
</div>
|
||||
|
||||
<!-- Gear -->
|
||||
<div class="gauge gauge-gear">
|
||||
<div class="gauge-value mono">{{ primaryFeed.gear ?? '-' }}</div>
|
||||
<div class="gauge-unit">GEAR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Bars -->
|
||||
<div class="input-bars">
|
||||
<div class="input-bar-row">
|
||||
<span class="input-label">THR</span>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill bar-throttle"
|
||||
:style="{ width: throttleWidth(primaryFeed.throttle) }"
|
||||
/>
|
||||
</div>
|
||||
<span class="input-value mono">{{ Math.round((primaryFeed.throttle ?? 0) * 100) }}%</span>
|
||||
</div>
|
||||
<div class="input-bar-row">
|
||||
<span class="input-label">BRK</span>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill bar-brake"
|
||||
:style="{ width: brakeWidth(primaryFeed.brake) }"
|
||||
/>
|
||||
</div>
|
||||
<span class="input-value mono">{{ Math.round((primaryFeed.brake ?? 0) * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lap info -->
|
||||
<div class="lap-info">
|
||||
<div class="lap-item">
|
||||
<span class="lap-label">Lap Time</span>
|
||||
<span class="mono">{{ primaryFeed.lap_time ?? '-:--.---' }}</span>
|
||||
</div>
|
||||
<div class="lap-item">
|
||||
<span class="lap-label">Spline</span>
|
||||
<span class="mono">{{ (primaryFeed.spline_position ?? 0).toFixed(3) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Details -->
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>All Feeds</h2>
|
||||
<span v-if="telemetry.lastUpdate" class="text-muted" style="font-size: 0.8rem">
|
||||
Updated {{ telemetry.lastUpdate.toLocaleTimeString() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
<FeedStatus
|
||||
v-for="feed in telemetry.feeds"
|
||||
:key="feed.port"
|
||||
:feed="feed"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="telemetry.feeds.length === 0" class="text-secondary text-muted mt-2">
|
||||
Waiting for feeds...
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status-bar-error {
|
||||
border-color: rgba(248, 113, 113, 0.3);
|
||||
background: rgba(248, 113, 113, 0.05);
|
||||
}
|
||||
|
||||
.status-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.primary-display {
|
||||
background: linear-gradient(135deg, var(--color-surface) 0%, #1e2235 100%);
|
||||
}
|
||||
|
||||
.primary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr 0.8fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.gauge-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gauge-speed .gauge-value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.gauge-rpm .gauge-value {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.gauge-gear .gauge-value {
|
||||
color: var(--color-success);
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.gauge-unit {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.input-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
width: 2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
background: var(--color-background);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.bar-throttle {
|
||||
background: linear-gradient(90deg, #22c55e, #4ade80);
|
||||
}
|
||||
|
||||
.bar-brake {
|
||||
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||
}
|
||||
|
||||
.input-value {
|
||||
width: 3rem;
|
||||
text-align: right;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.lap-info {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.lap-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.lap-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.primary-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.gauge-gear {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.gauge-value {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.gauge-gear .gauge-value {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
382
src/views/SessionDetailView.vue
Normal file
382
src/views/SessionDetailView.vue
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSessionsStore } from '@/stores/sessions'
|
||||
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useSessionsStore()
|
||||
|
||||
const showStartRun = ref(false)
|
||||
const runFeedName = ref('ksudp')
|
||||
const runLabel = ref('')
|
||||
|
||||
const sessionId = computed(() => props.id ?? (route.params.id as string))
|
||||
const session = computed(() => store.currentSession)
|
||||
|
||||
const feedOptions = ['ksudp', 'telemetry_tool', 'pcars']
|
||||
|
||||
const hasActiveRun = computed(() => {
|
||||
return session.value?.runs.some((r) => r.status === 'recording') ?? false
|
||||
})
|
||||
|
||||
const isCompleted = computed(() => session.value?.status === 'completed')
|
||||
|
||||
const analysisData = computed(() => {
|
||||
if (!session.value?.analysis) return null
|
||||
return session.value.analysis as Record<string, unknown>
|
||||
})
|
||||
|
||||
const shiftAnalysis = computed((): ShiftPointAnalysis | null => {
|
||||
if (!analysisData.value) return null
|
||||
// The analysis field is the full analysis object
|
||||
const data = analysisData.value
|
||||
if (data && typeof data === 'object' && 'gear_ranges' in data) {
|
||||
return data as unknown as ShiftPointAnalysis
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const trackAnalysis = computed((): TrackMapAnalysis | null => {
|
||||
if (!analysisData.value) return null
|
||||
const data = analysisData.value
|
||||
if (data && typeof data === 'object' && 'boundaries' in data) {
|
||||
return data as unknown as TrackMapAnalysis
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchSession(sessionId.value)
|
||||
})
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
async function handleStartRun() {
|
||||
const run = await store.startRun(sessionId.value, {
|
||||
feed_name: runFeedName.value,
|
||||
label: runLabel.value || undefined,
|
||||
})
|
||||
if (run) {
|
||||
showStartRun.value = false
|
||||
runLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopRun(runId: string) {
|
||||
await store.stopRun(sessionId.value, runId)
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
const result = await store.completeSession(sessionId.value)
|
||||
if (result) {
|
||||
// Refresh to get analysis
|
||||
await store.fetchSession(sessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReAnalyze() {
|
||||
const result = await store.reAnalyze(sessionId.value)
|
||||
if (result !== null) {
|
||||
await store.fetchSession(sessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function viewAnalysis() {
|
||||
if (!session.value) return
|
||||
if (session.value.use_case === 'shift_points') {
|
||||
router.push({ name: 'shift-points', params: { id: session.value.id } })
|
||||
} else if (session.value.use_case === 'track_map') {
|
||||
router.push({ name: 'track-map', params: { id: session.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm('Delete this session?')) {
|
||||
await store.deleteSession(sessionId.value)
|
||||
router.push('/sessions')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="session-detail">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<button class="btn btn-secondary btn-sm" @click="router.push('/sessions')">← Back</button>
|
||||
<h2 class="page-title">
|
||||
{{ session?.label || 'Session' }}
|
||||
</h2>
|
||||
<span v-if="session" :class="['badge', session.status === 'active' ? 'badge-primary' : 'badge-muted']">
|
||||
{{ session.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="store.error" class="card mb-3 text-error">{{ store.error }}</div>
|
||||
<div v-if="store.loading && !session" class="card text-secondary">Loading...</div>
|
||||
|
||||
<template v-if="session">
|
||||
<!-- Session Info -->
|
||||
<section class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>Session Info</h2>
|
||||
<div class="flex gap-1">
|
||||
<button v-if="!isCompleted" class="btn btn-danger btn-sm" @click="handleDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Use Case</span>
|
||||
<span>{{ session.use_case.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Created</span>
|
||||
<span>{{ formatTime(session.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Runs</span>
|
||||
<span>{{ session.runs.length }}</span>
|
||||
</div>
|
||||
<div v-if="session.completed_at" class="info-item">
|
||||
<span class="info-label">Completed</span>
|
||||
<span>{{ formatTime(session.completed_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Runs -->
|
||||
<section class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>Runs</h2>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-if="!isCompleted && !hasActiveRun"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="showStartRun = !showStartRun"
|
||||
>
|
||||
{{ showStartRun ? 'Cancel' : '+ Start Run' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Run Form -->
|
||||
<div v-if="showStartRun" class="run-form mb-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<select v-model="runFeedName" class="form-select">
|
||||
<option v-for="f in feedOptions" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="runLabel"
|
||||
placeholder="Label (e.g. 'left', 'right')"
|
||||
class="form-input"
|
||||
@keyup.enter="handleStartRun"
|
||||
/>
|
||||
<button class="btn btn-primary btn-sm" @click="handleStartRun">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="session.runs.length === 0" class="text-secondary text-muted">
|
||||
No runs yet. Start a run to capture telemetry data.
|
||||
</div>
|
||||
|
||||
<div v-else class="run-list">
|
||||
<div v-for="run in session.runs" :key="run.id" class="run-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['badge', run.status === 'recording' ? 'badge-warning' : 'badge-muted']">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
<span class="run-label">{{ run.label || 'Untitled Run' }}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary mono" style="font-size: 0.8rem">{{ run.feed_name }}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary" style="font-size: 0.8rem">{{ run.frame_count }} frames</span>
|
||||
<span v-if="run.data_truncated" class="badge badge-warning" style="font-size: 0.65rem">TRUNCATED</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-if="run.status === 'recording'"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="handleStopRun(run.id)"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Complete / Analysis -->
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Analysis</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCompleted" class="text-secondary">
|
||||
<p class="mb-2">Complete the session to trigger automatic analysis.</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="session.runs.length === 0 || hasActiveRun"
|
||||
@click="handleComplete"
|
||||
>
|
||||
✓ Complete Session
|
||||
</button>
|
||||
<p v-if="hasActiveRun" class="text-warning mt-1" style="font-size: 0.85rem">
|
||||
Stop all active runs before completing.
|
||||
</p>
|
||||
<p v-if="session.runs.length === 0" class="text-muted mt-1" style="font-size: 0.85rem">
|
||||
Add at least one run before completing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="analysisData">
|
||||
<div class="analysis-summary mb-2">
|
||||
<span class="badge badge-success">Analyzed</span>
|
||||
<span class="text-secondary" style="font-size: 0.85rem; margin-left: 0.5rem">
|
||||
{{ session.use_case.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats for shift points -->
|
||||
<div v-if="shiftAnalysis" class="analysis-stats grid grid-2 mb-2">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Recommended Shift RPM</span>
|
||||
<span class="stat-value mono">{{ Math.round(shiftAnalysis.recommended_shift_rpm ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Shift Events Detected</span>
|
||||
<span class="stat-value mono">{{ shiftAnalysis.shift_events.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats for track map -->
|
||||
<div v-if="trackAnalysis" class="analysis-stats grid grid-2 mb-2">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Boundaries</span>
|
||||
<span class="stat-value mono">{{ trackAnalysis.boundaries.length }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Total Points</span>
|
||||
<span class="stat-value mono">{{ trackAnalysis.total_points.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" @click="viewAnalysis">
|
||||
📊 View Full Analysis
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="handleReAnalyze">
|
||||
🔄 Re-analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-secondary">
|
||||
<p>Analysis not available.</p>
|
||||
<button class="btn btn-secondary btn-sm mt-1" @click="handleReAnalyze">
|
||||
Run Analysis
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.run-form {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.run-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.run-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.run-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-input {
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
179
src/views/SessionsView.vue
Normal file
179
src/views/SessionsView.vue
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useSessionsStore } from '@/stores/sessions'
|
||||
import type { UseCase } from '@/types'
|
||||
|
||||
const store = useSessionsStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const newUseCase = ref<UseCase>('shift_points')
|
||||
const newLabel = ref('')
|
||||
|
||||
const useCaseOptions: { value: UseCase; label: string }[] = [
|
||||
{ value: 'shift_points', label: 'Shift Points' },
|
||||
{ value: 'track_map', label: 'Track Map' },
|
||||
{ value: 'racing_line', label: 'Racing Line' },
|
||||
{ value: 'braking_analysis', label: 'Braking Analysis' },
|
||||
{ value: 'lap_times', label: 'Lap Times' },
|
||||
{ value: 'sector_analysis', label: 'Sector Analysis' },
|
||||
{ value: 'general', label: 'General' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchSessions()
|
||||
})
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function useCaseLabel(uc: string): string {
|
||||
return useCaseOptions.find((o) => o.value === uc)?.label ?? uc
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const session = await store.createSession({
|
||||
use_case: newUseCase.value,
|
||||
label: newLabel.value || undefined,
|
||||
})
|
||||
if (session) {
|
||||
showCreate.value = false
|
||||
newLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm('Delete this session?')) {
|
||||
await store.deleteSession(id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sessions-view">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="page-title">Sessions</h2>
|
||||
<button class="btn btn-primary" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? '✕ Cancel' : '+ New Session' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Form -->
|
||||
<div v-if="showCreate" class="card mb-3">
|
||||
<h3 class="mb-2">Create New Session</h3>
|
||||
<div class="flex gap-2 items-center">
|
||||
<select v-model="newUseCase" class="form-select">
|
||||
<option v-for="opt in useCaseOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="newLabel"
|
||||
placeholder="Label (optional)"
|
||||
class="form-input"
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
<button class="btn btn-primary" :disabled="store.loading" @click="handleCreate">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="card mb-3 text-error">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loading && store.sessions.length === 0" class="card text-secondary">
|
||||
Loading sessions...
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
<div v-if="store.sessions.length === 0 && !store.loading" class="card text-secondary">
|
||||
No sessions yet. Create one to start analyzing your driving.
|
||||
</div>
|
||||
|
||||
<div v-else class="session-cards">
|
||||
<div v-for="session in store.sessions" :key="session.id" class="card session-card">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['badge', session.status === 'active' ? 'badge-primary' : 'badge-muted']">
|
||||
{{ session.status }}
|
||||
</span>
|
||||
<span class="badge badge-primary">{{ useCaseLabel(session.use_case) }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<RouterLink :to="`/sessions/${session.id}`" class="btn btn-secondary btn-sm">
|
||||
Open
|
||||
</RouterLink>
|
||||
<button class="btn btn-danger btn-sm" @click="handleDelete(session.id)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-meta">
|
||||
<span class="session-label">{{ session.label || 'Untitled' }}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary">{{ session.runs.length }} run(s)</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-muted" style="font-size: 0.8rem">{{ formatTime(session.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.session-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.session-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
262
src/views/ShiftPointsView.vue
Normal file
262
src/views/ShiftPointsView.vue
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Bar, Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from 'chart.js'
|
||||
import type { ShiftPointAnalysis } from '@/types'
|
||||
import * as api from '@/api/client'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sessionId = computed(() => route.params.id as string)
|
||||
const analysis = ref<ShiftPointAnalysis | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.getAnalysis(sessionId.value)
|
||||
if (data && 'gear_ranges' in data) {
|
||||
analysis.value = data as unknown as ShiftPointAnalysis
|
||||
} 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
|
||||
}
|
||||
})
|
||||
|
||||
// Gear RPM range chart data
|
||||
const gearRangeChartData = computed(() => {
|
||||
if (!analysis.value) return null
|
||||
const ranges = analysis.value.gear_ranges.filter((g) => g.gear >= 1)
|
||||
return {
|
||||
labels: ranges.map((g) => g.gear_display),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Min RPM',
|
||||
data: ranges.map((g) => g.min_rpm),
|
||||
backgroundColor: 'rgba(79, 143, 247, 0.3)',
|
||||
borderColor: 'rgba(79, 143, 247, 0.8)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
{
|
||||
label: 'Avg RPM',
|
||||
data: ranges.map((g) => g.avg_rpm),
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.3)',
|
||||
borderColor: 'rgba(52, 211, 153, 0.8)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
{
|
||||
label: 'Max RPM',
|
||||
data: ranges.map((g) => g.max_rpm),
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.3)',
|
||||
borderColor: 'rgba(248, 113, 113, 0.8)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const gearRangeOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3' } },
|
||||
title: { display: true, text: 'Gear RPM Ranges', color: '#e2e4ea' },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
||||
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'RPM', color: '#8b8fa3' } },
|
||||
},
|
||||
}
|
||||
|
||||
// Shift events timeline data
|
||||
const shiftEventsChartData = computed(() => {
|
||||
if (!analysis.value || analysis.value.shift_events.length === 0) return null
|
||||
const events = analysis.value.shift_events
|
||||
return {
|
||||
labels: events.map((_, i) => `#${i + 1}`),
|
||||
datasets: [
|
||||
{
|
||||
label: 'RPM Before Shift',
|
||||
data: events.map((e) => e.rpm_before_shift),
|
||||
borderColor: 'rgba(248, 113, 113, 1)',
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
pointRadius: 4,
|
||||
},
|
||||
{
|
||||
label: 'RPM After Shift',
|
||||
data: events.map((e) => e.rpm_after_shift),
|
||||
borderColor: 'rgba(52, 211, 153, 1)',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
pointRadius: 4,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const shiftEventsOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3' } },
|
||||
title: { display: true, text: 'Shift Events — RPM Before/After', color: '#e2e4ea' },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
||||
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'RPM', color: '#8b8fa3' } },
|
||||
},
|
||||
}
|
||||
|
||||
function gearLabel(gear: number): string {
|
||||
if (gear === -1) return 'R'
|
||||
if (gear === 0) return 'N'
|
||||
return gear.toString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shift-points-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">Shift Point Analysis</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card text-secondary">Loading analysis...</div>
|
||||
<div v-else-if="error" class="card text-error">{{ error }}</div>
|
||||
|
||||
<template v-else-if="analysis">
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-3 mb-3">
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Recommended Shift RPM</span>
|
||||
<span class="stat-value mono">{{ Math.round(analysis.recommended_shift_rpm ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Shift Events</span>
|
||||
<span class="stat-value mono">{{ analysis.shift_events.length }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Frames Analyzed</span>
|
||||
<span class="stat-value mono">{{ analysis.total_frames_analyzed.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gear RPM Ranges Chart -->
|
||||
<div v-if="gearRangeChartData" class="card mb-3">
|
||||
<Bar :data="gearRangeChartData" :options="gearRangeOptions" />
|
||||
</div>
|
||||
|
||||
<!-- Shift Events Chart -->
|
||||
<div v-if="shiftEventsChartData" class="card mb-3">
|
||||
<Line :data="shiftEventsChartData" :options="shiftEventsOptions" />
|
||||
</div>
|
||||
|
||||
<!-- Shift Events Table -->
|
||||
<div v-if="analysis.shift_events.length > 0" class="card">
|
||||
<div class="card-header">
|
||||
<h2>Shift Events Detail</h2>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>From → To</th>
|
||||
<th>RPM Before</th>
|
||||
<th>RPM After</th>
|
||||
<th>RPM Drop</th>
|
||||
<th>Speed (km/h)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(event, i) in analysis.shift_events" :key="i">
|
||||
<td class="mono">{{ i + 1 }}</td>
|
||||
<td>{{ gearLabel(event.from_gear) }} → {{ gearLabel(event.to_gear) }}</td>
|
||||
<td class="mono">{{ Math.round(event.rpm_before_shift) }}</td>
|
||||
<td class="mono">{{ Math.round(event.rpm_after_shift) }}</td>
|
||||
<td class="mono">{{ Math.round(event.rpm_drop) }}</td>
|
||||
<td class="mono">{{ Math.round(event.speed_kmh) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
</style>
|
||||
330
src/views/TrackMapView.vue
Normal file
330
src/views/TrackMapView.vue
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { TrackMapAnalysis, TrackPoint } from '@/types'
|
||||
import * as api from '@/api/client'
|
||||
|
||||
const BOUNDARY_COLORS = ['#f87171', '#60a5fa', '#fbbf24', '#34d399', '#a78bfa']
|
||||
let resizeRafId: number | null = null
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sessionId = computed(() => route.params.id as string)
|
||||
const analysis = ref<TrackMapAnalysis | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.getAnalysis(sessionId.value)
|
||||
if (data && 'boundaries' in data) {
|
||||
analysis.value = data as unknown as TrackMapAnalysis
|
||||
} else {
|
||||
error.value = 'Track map analysis not available'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load analysis'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch([analysis, canvas], () => {
|
||||
if (analysis.value && canvas.value) {
|
||||
drawTrackMap()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function drawTrackMap() {
|
||||
if (!canvas.value || !analysis.value) return
|
||||
|
||||
const ctx = canvas.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const allPoints = analysis.value.boundaries.flatMap((b) => b.points)
|
||||
if (analysis.value.center_line) {
|
||||
allPoints.push(...analysis.value.center_line.points)
|
||||
}
|
||||
|
||||
if (allPoints.length === 0) return
|
||||
|
||||
const minX = allPoints.reduce((m, p) => p.x < m ? p.x : m, Infinity)
|
||||
const maxX = allPoints.reduce((m, p) => p.x > m ? p.x : m, -Infinity)
|
||||
const minY = allPoints.reduce((m, p) => p.y < m ? p.y : m, Infinity)
|
||||
const maxY = allPoints.reduce((m, p) => p.y > m ? p.y : m, -Infinity)
|
||||
|
||||
const width = canvas.value.width
|
||||
const height = canvas.value.height
|
||||
const padding = 40
|
||||
const drawWidth = width - padding * 2
|
||||
const drawHeight = height - padding * 2
|
||||
|
||||
const rangeX = maxX - minX || 1
|
||||
const rangeY = maxY - minY || 1
|
||||
const scale = Math.min(drawWidth / rangeX, drawHeight / rangeY)
|
||||
|
||||
const offsetX = padding + (drawWidth - rangeX * scale) / 2
|
||||
const offsetY = padding + (drawHeight - rangeY * scale) / 2
|
||||
|
||||
function toCanvas(point: TrackPoint): [number, number] {
|
||||
return [
|
||||
offsetX + (point.x - minX) * scale,
|
||||
offsetY + (maxY - point.y) * scale,
|
||||
]
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#0f1117'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
analysis.value.boundaries.forEach((boundary, idx) => {
|
||||
if (boundary.points.length < 2) return
|
||||
const color = BOUNDARY_COLORS[idx % BOUNDARY_COLORS.length]
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
ctx.globalAlpha = 0.7
|
||||
ctx.beginPath()
|
||||
const [sx, sy] = toCanvas(boundary.points[0])
|
||||
ctx.moveTo(sx, sy)
|
||||
for (let i = 1; i < boundary.points.length; i++) {
|
||||
const [px, py] = toCanvas(boundary.points[i])
|
||||
ctx.lineTo(px, py)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
const labelPoint = boundary.points[Math.floor(boundary.points.length / 2)]
|
||||
const [lx, ly] = toCanvas(labelPoint)
|
||||
ctx.fillStyle = color
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.fillText(boundary.label, lx + 5, ly - 5)
|
||||
})
|
||||
|
||||
if (analysis.value.center_line && analysis.value.center_line.points.length >= 2) {
|
||||
ctx.strokeStyle = '#34d399'
|
||||
ctx.lineWidth = 3
|
||||
ctx.globalAlpha = 0.9
|
||||
ctx.beginPath()
|
||||
const [sx, sy] = toCanvas(analysis.value.center_line.points[0])
|
||||
ctx.moveTo(sx, sy)
|
||||
for (let i = 1; i < analysis.value.center_line.points.length; i++) {
|
||||
const [px, py] = toCanvas(analysis.value.center_line.points[i])
|
||||
ctx.lineTo(px, py)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
const [startX, startY] = toCanvas(analysis.value.center_line.points[0])
|
||||
const [endX, endY] = toCanvas(analysis.value.center_line.points[analysis.value.center_line.points.length - 1])
|
||||
ctx.fillStyle = '#34d399'
|
||||
ctx.beginPath()
|
||||
ctx.arc(startX, startY, 5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#f87171'
|
||||
ctx.beginPath()
|
||||
ctx.arc(endX, endY, 5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (canvas.value) {
|
||||
const container = canvas.value.parentElement
|
||||
if (container) {
|
||||
canvas.value.width = container.clientWidth
|
||||
canvas.value.height = Math.max(500, container.clientWidth * 0.6)
|
||||
drawTrackMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedResize = () => {
|
||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId)
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
handleResize()
|
||||
resizeRafId = null
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResize()
|
||||
window.addEventListener('resize', debouncedResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId)
|
||||
window.removeEventListener('resize', debouncedResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="track-map-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">Track Map</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card text-secondary">Loading analysis...</div>
|
||||
<div v-else-if="error" class="card text-error">{{ error }}</div>
|
||||
|
||||
<template v-else-if="analysis">
|
||||
<div class="grid grid-3 mb-3">
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Boundaries</span>
|
||||
<span class="stat-value mono">{{ analysis.boundaries.length }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Total Points</span>
|
||||
<span class="stat-value mono">{{ analysis.total_points.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Center Line</span>
|
||||
<span class="stat-value" :class="analysis.center_line ? 'text-success' : 'text-muted'">
|
||||
{{ analysis.center_line ? '✓ Computed' : '✗ N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>Track Map</h2>
|
||||
<div class="flex gap-2">
|
||||
<span v-for="(boundary, idx) in analysis.boundaries" :key="boundary.label" class="legend-item">
|
||||
<span class="legend-dot" :style="{ background: BOUNDARY_COLORS[idx % BOUNDARY_COLORS.length] }" />
|
||||
{{ boundary.label }} ({{ boundary.point_count }})
|
||||
</span>
|
||||
<span v-if="analysis.center_line" class="legend-item">
|
||||
<span class="legend-dot" style="background: #34d399" />
|
||||
Center Line
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-container">
|
||||
<canvas ref="canvas" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Boundaries</h2>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Points</th>
|
||||
<th>Spline Range</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="boundary in analysis.boundaries" :key="boundary.label">
|
||||
<td>{{ boundary.label }}</td>
|
||||
<td class="mono">{{ boundary.point_count.toLocaleString() }}</td>
|
||||
<td class="mono">
|
||||
{{ boundary.points.length > 0 ? boundary.points[0].spline.toFixed(3) : '-' }}
|
||||
–
|
||||
{{ boundary.points.length > 0 ? boundary.points[boundary.points.length - 1].spline.toFixed(3) : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue