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:
Jan-Peter von Hunnius 2026-06-05 16:12:33 +02:00
commit cf9af73281
27 changed files with 5123 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
# Dependencies
node_modules/
# Build output
dist/
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# TypeScript build info
*.tsbuildinfo
# Environment files with secrets
.env.local
.env.*.local

57
AGENTS.md Normal file
View file

@ -0,0 +1,57 @@
# AGENTS.md — Racecraft Project Context
## Mandatory Reference
**Before starting any work on this project, always read the Joplin note that contains the full project plan and architecture.**
- **Joplin Note ID**: `6c0dcb2a567348fd9796f50c790082e4`
- **Title**: "Sim Racing Telemetry Analysis Platform — Project Plan"
- **Tags**: sim-racing, lua, telemetry, rust, project-plan, assetto-corsa
Use the `joplin_get_note` tool with note ID `6c0dcb2a567348fd9796f50c790082e4` to retrieve the full project plan.
## Project Overview
`racecraft` is a Vue.js 3 + TypeScript web client for the [rusty-telemetry](https://codeberg.org/jhunnius/rusty-telemetry) sim racing telemetry platform. It provides live telemetry visualization, session management, and data analysis through a browser-based interface.
## Companion Repository
| Field | Value |
|-------|-------|
| **Repo** | `rusty-telemetry` |
| **Path** | `~/RustroverProjects/rusty-telemetry` |
| **Language** | Rust |
| **Purpose** | UDP listener, REST API backend, analysis engine |
## Tech Stack
- Vue 3.5 (Composition API, `<script setup>`)
- TypeScript 5.7
- Vite 6 (dev server with API proxy)
- Pinia 3 (state management)
- Vue Router 4 (client-side routing)
- Axios (HTTP client)
- Chart.js 4 + vue-chartjs 5 (data visualization)
- HTML Canvas 2D (track map rendering)
## Key Ports
| Port | Component | Purpose |
|------|-----------|---------|
| 8080 | rusty-telemetry API | REST API backend |
| 5173 | racecraft (Vite dev) | Frontend dev server |
| 9996 | rusty-telemetry UDP | AC KSUDP handshake |
| 10101 | rusty-telemetry UDP | AC Telemetry Tool |
| 5606 | rusty-telemetry UDP | PCARS telemetry |
## API Communication
Racecraft communicates exclusively with the rusty-telemetry REST API. In development, Vite proxies `/api/*` to `http://127.0.0.1:8080`. See the rusty-telemetry README for full endpoint documentation.
## Commands
```bash
npm run dev # Start dev server (0.0.0.0:5173)
npm run build # TypeScript check + Vite production build
npm run preview # Preview production build
```

174
README.md Normal file
View file

@ -0,0 +1,174 @@
# Racecraft
Web-based sim racing telemetry analysis client. Connects to [rusty-telemetry](https://codeberg.org/jhunnius/rusty-telemetry) via its REST API and provides live telemetry visualization, session management, and data analysis.
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Sim Machine │
│ │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ Assetto │ │ rusty- │ │
│ │ Corsa │──│ telemetry │ │
│ │ (UDP out) │ │ :8080 (API) │ │
│ └──────────────┘ └───────────────┘ │
│ ┌───────────────┐ │
│ │ racecraft │ │
│ │ :5173 (Vite) │ │
│ └───────────────┘ │
└─────────────────────────────────────────────────┘
│
LAN / WiFi
│
┌──────────────────┐
│ Other machine │
│ Browser │
└──────────────────┘
```
- **rusty-telemetry** listens for UDP telemetry from sim games on ports 9996, 10101, 5606
- **racecraft** is a Vue.js SPA that talks to the rusty-telemetry REST API via a Vite dev proxy
- The Vite dev server binds to `0.0.0.0`, making it accessible from other machines on the LAN
- API calls (`/api/*`) are proxied to `http://127.0.0.1:8080`
## Features
### Dashboard
- System status overview: API server connectivity, sim connection state, active recording
- Live feed cards with health indicators, frequency, and telemetry snapshot
- Quick access to live telemetry and session management
- Recent sessions list
### Live Telemetry
- Real-time gauges: speed (km/h), RPM, gear indicator
- Throttle and brake input bars
- Lap time and track position (spline)
- Per-feed status cards with health, frequency, and packet stats
- Polling at 250ms for responsive updates
### Session Management
- Create analysis sessions with use case selection (Shift Points, Track Map, etc.)
- Start/stop runs within a session to capture telemetry data
- Complete sessions to trigger automatic analysis
- Session detail view with run history and status
### Shift Point Analysis
- Gear RPM range visualization (min/avg/max bar chart)
- Shift event timeline (RPM before/after each upshift)
- Recommended shift RPM from aggregated data
- Detailed shift event table
### Track Map
- Canvas-rendered track boundaries from coordinate data
- Center line computation from left/right boundary runs
- Boundary legend with point counts
- Responsive canvas that redraws on window resize
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | Vue 3.5 (Composition API, `<script setup>`) |
| Language | TypeScript 5.7 |
| Build | Vite 6 |
| Routing | Vue Router 4 |
| State | Pinia 3 |
| HTTP | Axios |
| Charts | Chart.js 4 + vue-chartjs 5 |
| Track Map | HTML Canvas 2D |
| Styling | CSS with custom properties (dark theme) |
## Getting Started
### Prerequisites
- Node.js 18+ and npm
- [rusty-telemetry](https://codeberg.org/jhunnius/rusty-telemetry) running on the same machine
### Install
```bash
cd ~/WebstormProjects/racecraft
npm install
```
### Run (Development)
Start both the backend and frontend:
```bash
# Terminal 1 — Backend (rusty-telemetry)
cd ~/RustroverProjects/rusty-telemetry
RUSTY_TELEMETRY_API_HOST=0.0.0.0 cargo run --release
# Terminal 2 — Frontend (racecraft)
cd ~/WebstormProjects/racecraft
npm run dev
```
Vite will output:
```
➜ Local: http://localhost:5173/
➜ Network: http://192.168.1.x:5173/
```
- **From the same machine**: open `http://localhost:5173`
- **From another machine**: open `http://192.168.1.x:5173` (the Network URL)
The `RUSTY_TELEMETRY_API_HOST=0.0.0.0` env var makes the backend API accessible from all network interfaces. In dev mode, the Vite proxy handles routing `/api/*` to the backend.
### Production Build
```bash
npm run build
```
Output is in `dist/`. Serve with any static file server, or configure axum in rusty-telemetry to serve the built files.
## Project Structure
```
racecraft/
├── index.html
├── package.json
├── vite.config.ts # host: 0.0.0.0, proxy /api → localhost:8080
├── tsconfig.json
├── src/
│ ├── main.ts # App bootstrap (Vue + Pinia + Router)
│ ├── App.vue # Root layout with navigation
│ ├── router/index.ts # 6 routes with lazy loading
│ ├── types/index.ts # TypeScript interfaces matching API JSON shapes
│ ├── api/client.ts # Typed Axios client (all API endpoints)
│ ├── stores/
│ │ ├── telemetry.ts # Live telemetry with polling + derived status
│ │ └── sessions.ts # Session CRUD, run management, analysis
│ ├── views/
│ │ ├── DashboardView.vue # System status, feeds, quick actions
│ │ ├── LiveView.vue # Real-time gauges and telemetry
│ │ ├── SessionsView.vue # Session list with create/delete
│ │ ├── SessionDetailView.vue # Run workflow, complete, analyze
│ │ ├── ShiftPointsView.vue # RPM charts and shift event table
│ │ └── TrackMapView.vue # Canvas track map renderer
│ ├── components/
│ │ └── FeedStatus.vue # Reusable feed card with health + telemetry
│ └── assets/
│ └── main.css # Dark theme, CSS variables, utilities
└── public/
└── favicon.ico
```
## API Contract
Racecraft consumes the rusty-telemetry REST API (19 endpoints). See the [rusty-telemetry README](https://codeberg.org/jhunnius/rusty-telemetry) for full endpoint documentation.
Key endpoints used:
- `GET /api/live` — polled every 250ms–1000ms for live telemetry
- `GET /api/sessions` / `POST /api/sessions` — session management
- `POST /api/sessions/{id}/runs` / `POST /api/sessions/{id}/runs/{run_id}/stop` — run capture
- `POST /api/sessions/{id}/complete` — complete session and trigger analysis
- `GET /api/sessions/{id}/analysis` — retrieve analysis results
## License
Private project — not yet licensed for distribution.

6
env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Racecraft</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1892
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "racecraft",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^3.0.0",
"axios": "^1.9.0",
"chart.js": "^4.4.9",
"vue-chartjs": "^5.3.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "~5.7.0",
"vite": "^6.3.0",
"vue-tsc": "^2.2.8"
}
}

0
public/favicon.ico Normal file
View file

115
src/App.vue Normal file
View file

@ -0,0 +1,115 @@
<script setup lang="ts">
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const currentPath = computed(() => route.path)
const navItems = [
{ path: '/', label: 'Dashboard', icon: '🏠' },
{ path: '/live', label: 'Live', icon: '📡' },
{ path: '/sessions', label: 'Sessions', icon: '📊' },
]
</script>
<template>
<div class="app-layout">
<header class="app-header">
<div class="logo">
<span class="logo-icon">🏁</span>
<h1>Racecraft</h1>
</div>
<nav class="main-nav">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
:class="{ active: currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path)) }"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</RouterLink>
</nav>
</header>
<main class="app-main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
gap: 2rem;
padding: 0 1.5rem;
height: 56px;
background: var(--color-header);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo-icon {
font-size: 1.5rem;
}
.logo h1 {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.main-nav {
display: flex;
gap: 0.25rem;
}
.main-nav a {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 6px;
text-decoration: none;
color: var(--color-text-secondary);
font-size: 0.9rem;
font-weight: 500;
transition: all 0.15s ease;
}
.main-nav a:hover {
background: var(--color-hover);
color: var(--color-text);
}
.main-nav a.active {
background: var(--color-active);
color: var(--color-primary);
}
.nav-icon {
font-size: 1rem;
}
.app-main {
flex: 1;
padding: 1.5rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
}
</style>

118
src/api/client.ts Normal file
View file

@ -0,0 +1,118 @@
import axios from 'axios'
import type {
LiveTelemetryResponse,
RecordingManifest,
StartRecordingRequest,
UseCaseSession,
CreateSessionRequest,
StartRunRequest,
SessionRun,
UseCase,
Game,
} from '@/types'
const api = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// ── Live Telemetry ──
export async function getLiveTelemetry(): Promise<LiveTelemetryResponse> {
const res = await api.get<LiveTelemetryResponse>('/live')
return res.data
}
// ── Recordings ──
export async function listRecordings(): Promise<RecordingManifest[]> {
const res = await api.get<RecordingManifest[]>('/recordings')
return res.data
}
export async function getRecording(id: string): Promise<RecordingManifest> {
const res = await api.get<RecordingManifest>(`/recordings/${id}`)
return res.data
}
export async function startRecording(req?: StartRecordingRequest): Promise<RecordingManifest> {
const res = await api.post<RecordingManifest>('/recordings', req ?? {})
return res.data
}
export async function stopActiveRecording(): Promise<RecordingManifest> {
const res = await api.post<RecordingManifest>('/recordings/stop')
return res.data
}
export async function stopRecording(id: string): Promise<RecordingManifest> {
const res = await api.post<RecordingManifest>(`/recordings/${id}/stop`)
return res.data
}
export async function deleteRecording(id: string): Promise<void> {
await api.delete(`/recordings/${id}`)
}
// ── Sessions ──
export async function listSessions(): Promise<UseCaseSession[]> {
const res = await api.get<UseCaseSession[]>('/sessions')
return res.data
}
export async function getSession(id: string): Promise<UseCaseSession> {
const res = await api.get<UseCaseSession>(`/sessions/${id}`)
return res.data
}
export async function createSession(req: CreateSessionRequest): Promise<UseCaseSession> {
const res = await api.post<UseCaseSession>('/sessions', req)
return res.data
}
export async function deleteSession(id: string): Promise<void> {
await api.delete(`/sessions/${id}`)
}
export async function startRun(sessionId: string, req: StartRunRequest): Promise<SessionRun> {
const res = await api.post<SessionRun>(`/sessions/${sessionId}/runs`, req)
return res.data
}
export async function stopRun(sessionId: string, runId: string): Promise<SessionRun> {
const res = await api.post<SessionRun>(`/sessions/${sessionId}/runs/${runId}/stop`)
return res.data
}
export async function completeSession(id: string): Promise<UseCaseSession> {
const res = await api.post<UseCaseSession>(`/sessions/${id}/complete`)
return res.data
}
export async function getAnalysis(id: string): Promise<Record<string, unknown>> {
const res = await api.get<Record<string, unknown>>(`/sessions/${id}/analysis`)
return res.data
}
export async function reAnalyze(id: string): Promise<Record<string, unknown>> {
const res = await api.post<Record<string, unknown>>(`/sessions/${id}/analyze`)
return res.data
}
// ── Metadata ──
export async function getUseCases(): Promise<UseCase[]> {
const res = await api.get<UseCase[]>('/use-cases')
return res.data
}
export async function getGames(): Promise<Game[]> {
const res = await api.get<Game[]>('/games')
return res.data
}
export default api

268
src/assets/main.css Normal file
View file

@ -0,0 +1,268 @@
:root {
--color-background: #0f1117;
--color-surface: #1a1d27;
--color-surface-hover: #22263a;
--color-header: #161924;
--color-border: #2a2e3f;
--color-text: #e2e4ea;
--color-text-secondary: #8b8fa3;
--color-text-muted: #5c607a;
--color-primary: #4f8ff7;
--color-primary-dim: #3a6fd4;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-hover: rgba(79, 143, 247, 0.08);
--color-active: rgba(79, 143, 247, 0.15);
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-background);
color: var(--color-text);
line-height: 1.5;
min-height: 100vh;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1, h2, h3 {
font-weight: 600;
line-height: 1.3;
}
button {
cursor: pointer;
font-family: inherit;
font-size: inherit;
border: none;
outline: none;
}
input, select, textarea {
font-family: inherit;
font-size: inherit;
}
/* Utility classes */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.card-header h2 {
font-size: 1rem;
font-weight: 600;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
transition: all 0.15s ease;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-dim);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--color-surface-hover);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
border-color: var(--color-primary);
}
.btn-danger {
background: rgba(248, 113, 113, 0.15);
color: var(--color-error);
border: 1px solid rgba(248, 113, 113, 0.3);
}
.btn-danger:hover {
background: rgba(248, 113, 113, 0.25);
}
.btn-sm {
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
}
.mono {
font-family: var(--font-mono);
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-success {
background: rgba(52, 211, 153, 0.15);
color: var(--color-success);
}
.badge-warning {
background: rgba(251, 191, 36, 0.15);
color: var(--color-warning);
}
.badge-error {
background: rgba(248, 113, 113, 0.15);
color: var(--color-error);
}
.badge-muted {
background: rgba(139, 143, 163, 0.15);
color: var(--color-text-secondary);
}
.badge-primary {
background: rgba(79, 143, 247, 0.15);
color: var(--color-primary);
}
.grid {
display: grid;
gap: 1rem;
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 768px) {
.grid-2, .grid-3 {
grid-template-columns: 1fr;
}
}
.text-muted {
color: var(--color-text-muted);
}
.text-secondary {
color: var(--color-text-secondary);
}
.text-success {
color: var(--color-success);
}
.text-warning {
color: var(--color-warning);
}
.text-error {
color: var(--color-error);
}
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 0.5rem; }
.gap-2 { gap: 1rem; }
.gap-3 { gap: 1.5rem; }
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-background);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}

View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import type { LiveFeedSnapshot } from '@/types'
import { computed } from 'vue'
const props = defineProps<{
feed: LiveFeedSnapshot
}>()
const healthClass = computed(() => {
switch (props.feed.health) {
case 'alive': return 'badge-success'
case 'stale': return 'badge-warning'
case 'error': return 'badge-error'
default: return 'badge-muted'
}
})
const healthIcon = computed(() => {
switch (props.feed.health) {
case 'alive': return '●'
case 'stale': return '◐'
case 'error': return '✕'
default: return '○'
}
})
const statusMessage = computed(() => {
switch (props.feed.health) {
case 'alive':
return null // telemetry section handles this
case 'stale':
return 'Data stream stalled — sim may have paused or exited'
case 'error':
return 'Feed error — check network configuration'
default: // waiting
if (props.feed.port === 9996) {
return 'Waiting for AC handshake — start a session in Assetto Corsa'
}
return 'Waiting for incoming data on port ' + props.feed.port
}
})
</script>
<template>
<div class="feed-card">
<div class="feed-header">
<span class="feed-name">{{ feed.label }}</span>
<span :class="['badge', healthClass]">
{{ healthIcon }} {{ feed.health }}
</span>
</div>
<div class="feed-stats">
<div class="stat">
<span class="stat-label">Port</span>
<span class="stat-value mono">{{ feed.port }}</span>
</div>
<div class="stat">
<span class="stat-label">Freq</span>
<span class="stat-value mono">{{ feed.frequency_hz.toFixed(1) }} Hz</span>
</div>
<div class="stat">
<span class="stat-label">Packets</span>
<span class="stat-value mono">{{ feed.total_packets.toLocaleString() }}</span>
</div>
<div class="stat">
<span class="stat-label">Rate</span>
<span class="stat-value mono">{{ feed.data_rate_kb_s.toFixed(1) }} KB/s</span>
</div>
</div>
<!-- Live telemetry (only when alive) -->
<div v-if="feed.health === 'alive'" class="feed-telemetry">
<div class="telem-row">
<span class="telem-label">Speed</span>
<span class="telem-value mono">{{ feed.speed_kmh !== null ? Math.round(feed.speed_kmh) + ' km/h' : '---' }}</span>
</div>
<div class="telem-row">
<span class="telem-label">Gear</span>
<span class="telem-value mono">{{ feed.gear ?? '-' }}</span>
</div>
<div class="telem-row">
<span class="telem-label">RPM</span>
<span class="telem-value mono">{{ feed.engine_rpm !== null ? Math.round(feed.engine_rpm) : '---' }}</span>
</div>
<div class="telem-row">
<span class="telem-label">Lap</span>
<span class="telem-value mono">{{ feed.lap_time ?? '-:--.---' }}</span>
</div>
</div>
<!-- Status message for non-alive states -->
<div v-if="statusMessage" class="feed-message">
{{ statusMessage }}
</div>
</div>
</template>
<style scoped>
.feed-card {
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.75rem;
}
.feed-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}
.feed-name {
font-weight: 600;
font-size: 0.85rem;
}
.feed-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 0.75rem;
margin-bottom: 0.5rem;
}
.stat {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
}
.stat-label {
color: var(--color-text-muted);
}
.stat-value {
color: var(--color-text-secondary);
}
.feed-telemetry {
border-top: 1px solid var(--color-border);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.telem-row {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
}
.telem-label {
color: var(--color-text-muted);
}
.telem-value {
color: var(--color-text);
}
.feed-message {
border-top: 1px solid var(--color-border);
padding-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
line-height: 1.4;
}
</style>

10
src/main.ts Normal file
View file

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

42
src/router/index.ts Normal file
View file

@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: '/live',
name: 'live',
component: () => import('@/views/LiveView.vue'),
},
{
path: '/sessions',
name: 'sessions',
component: () => import('@/views/SessionsView.vue'),
},
{
path: '/sessions/:id',
name: 'session-detail',
component: () => import('@/views/SessionDetailView.vue'),
props: true,
},
{
path: '/analysis/shift-points/:id',
name: 'shift-points',
component: () => import('@/views/ShiftPointsView.vue'),
props: true,
},
{
path: '/analysis/track-map/:id',
name: 'track-map',
component: () => import('@/views/TrackMapView.vue'),
props: true,
},
],
})
export default router

136
src/stores/sessions.ts Normal file
View file

@ -0,0 +1,136 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { UseCaseSession, CreateSessionRequest, StartRunRequest, SessionRun } from '@/types'
import * as api from '@/api/client'
export const useSessionsStore = defineStore('sessions', () => {
const sessions = ref<UseCaseSession[]>([])
const currentSession = ref<UseCaseSession | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchSessions() {
loading.value = true
error.value = null
try {
sessions.value = await api.listSessions()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load sessions'
} finally {
loading.value = false
}
}
async function fetchSession(id: string) {
loading.value = true
error.value = null
try {
currentSession.value = await api.getSession(id)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load session'
} finally {
loading.value = false
}
}
async function createSession(req: CreateSessionRequest): Promise<UseCaseSession | null> {
try {
const session = await api.createSession(req)
sessions.value.unshift(session)
return session
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to create session'
return null
}
}
async function deleteSession(id: string) {
try {
await api.deleteSession(id)
sessions.value = sessions.value.filter((s) => s.id !== id)
if (currentSession.value?.id === id) {
currentSession.value = null
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to delete session'
}
}
async function startRun(sessionId: string, req: StartRunRequest): Promise<SessionRun | null> {
try {
const run = await api.startRun(sessionId, req)
if (currentSession.value?.id === sessionId) {
currentSession.value.runs.push(run)
const sIdx = sessions.value.findIndex((s) => s.id === sessionId)
if (sIdx !== -1) {
sessions.value[sIdx] = { ...sessions.value[sIdx], runs: [...currentSession.value!.runs] }
}
}
return run
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to start run'
return null
}
}
async function stopRun(sessionId: string, runId: string): Promise<SessionRun | null> {
try {
const run = await api.stopRun(sessionId, runId)
if (currentSession.value?.id === sessionId) {
const idx = currentSession.value.runs.findIndex((r) => r.id === runId)
if (idx !== -1) {
currentSession.value.runs[idx] = run
}
const sIdx = sessions.value.findIndex((s) => s.id === sessionId)
if (sIdx !== -1) {
sessions.value[sIdx] = { ...sessions.value[sIdx], runs: [...currentSession.value!.runs] }
}
}
return run
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to stop run'
return null
}
}
async function completeSession(id: string): Promise<UseCaseSession | null> {
try {
const session = await api.completeSession(id)
const idx = sessions.value.findIndex((s) => s.id === id)
if (idx !== -1) {
sessions.value[idx] = session
}
if (currentSession.value?.id === id) {
currentSession.value = session
}
return session
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to complete session'
return null
}
}
async function reAnalyze(id: string): Promise<Record<string, unknown> | null> {
try {
return await api.reAnalyze(id)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to analyze'
return null
}
}
return {
sessions,
currentSession,
loading,
error,
fetchSessions,
fetchSession,
createSession,
deleteSession,
startRun,
stopRun,
completeSession,
reAnalyze,
}
})

89
src/stores/telemetry.ts Normal file
View file

@ -0,0 +1,89 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { LiveTelemetryResponse, LiveFeedSnapshot } from '@/types'
import { getLiveTelemetry } from '@/api/client'
export const useTelemetryStore = defineStore('telemetry', () => {
const feeds = ref<LiveFeedSnapshot[]>([])
const activeRecording = ref<string | null>(null)
const connected = ref(false)
const error = ref<string | null>(null)
const lastUpdate = ref<Date | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const anyFeedAlive = computed(() => feeds.value.some((f) => f.health === 'alive'))
const anyFeedStale = computed(() => feeds.value.some((f) => f.health === 'stale'))
const totalPackets = computed(() => feeds.value.reduce((sum, f) => sum + f.total_packets, 0))
const simConnected = computed(() => anyFeedAlive.value || anyFeedStale.value)
const overallStatus = computed((): 'connected' | 'sim-active' | 'sim-stale' | 'disconnected' => {
if (!connected.value) return 'disconnected'
if (anyFeedAlive.value) return 'sim-active'
if (anyFeedStale.value) return 'sim-stale'
return 'connected'
})
const overallStatusLabel = computed((): string => {
switch (overallStatus.value) {
case 'sim-active': return 'Sim Connected'
case 'sim-stale': return 'Sim Stale'
case 'connected': return 'API Connected — No Sim'
case 'disconnected': return 'API Disconnected'
}
})
const overallStatusClass = computed((): string => {
switch (overallStatus.value) {
case 'sim-active': return 'badge-success'
case 'sim-stale': return 'badge-warning'
case 'connected': return 'badge-primary'
case 'disconnected': return 'badge-error'
}
})
async function fetchLive() {
try {
const data: LiveTelemetryResponse = await getLiveTelemetry()
feeds.value = data.feeds
activeRecording.value = data.active_recording
connected.value = true
error.value = null
lastUpdate.value = new Date()
} catch (e: unknown) {
connected.value = false
error.value = e instanceof Error ? e.message : 'Connection failed'
}
}
function startPolling(intervalMs = 250) {
stopPolling()
fetchLive()
pollTimer = setInterval(fetchLive, intervalMs)
}
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
feeds,
activeRecording,
connected,
error,
lastUpdate,
anyFeedAlive,
anyFeedStale,
totalPackets,
simConnected,
overallStatus,
overallStatusLabel,
overallStatusClass,
fetchLive,
startPolling,
stopPolling,
}
})

170
src/types/index.ts Normal file
View file

@ -0,0 +1,170 @@
// TypeScript interfaces matching the rusty-telemetry REST API JSON responses.
// Field names use snake_case to match Rust's serde serialization.
// ── Enums ──
export type UseCase =
| 'shift_points'
| 'racing_line'
| 'braking_analysis'
| 'lap_times'
| 'sector_analysis'
| 'track_map'
| 'general'
export type Game =
| 'assetto_corsa'
| 'assetto_corsa_competizione'
| 'project_cars1'
| 'project_cars2'
export type RecordingStatus = 'recording' | 'stopped'
export type SessionStatus = 'active' | 'completed'
export type RunStatus = 'recording' | 'stopped'
export type FeedSource = 'Ksudp' | 'TelemetryTool' | 'Pcars'
// ── API Responses ──
export interface LiveFeedSnapshot {
label: string
port: number
health: string
frequency_hz: number
total_packets: number
data_rate_kb_s: number
speed_kmh: number | null
gear: string | null
engine_rpm: number | null
throttle: number | null
brake: number | null
lap_time: string | null
spline_position: number | null
}
export interface LiveTelemetryResponse {
feeds: LiveFeedSnapshot[]
active_recording: string | null
}
// ── Recordings ──
export interface FeedStats {
port: number
name: string
bytes: number
file: string
}
export interface RecordingManifest {
id: string
name: string
game: string
track: string
car: string
use_case: string
status: RecordingStatus
created_at: string
stopped_at: string | null
feeds: FeedStats[]
notes: string
}
export interface StartRecordingRequest {
name?: string | null
game?: Game | null
track?: string | null
car?: string | null
use_case?: UseCase | null
notes?: string | null
}
// ── Sessions ──
export interface SessionRun {
id: string
label: string
feed_name: string
status: RunStatus
start_marker: number
end_marker: number | null
frame_count: number
data_truncated: boolean
}
export interface UseCaseSession {
id: string
use_case: UseCase
label: string
status: SessionStatus
created_at: string
completed_at: string | null
runs: SessionRun[]
analysis: AnalysisResult | null
}
export interface CreateSessionRequest {
use_case: UseCase
label?: string | null
}
export interface StartRunRequest {
feed_name: string
label?: string | null
}
// ── Analysis Results ──
// Shift Point Analysis (use_case = "shift_points")
export interface GearRpmRange {
gear: number
gear_display: string
min_rpm: number
max_rpm: number
avg_rpm: number
sample_count: number
}
export interface ShiftEvent {
from_gear: number
to_gear: number
rpm_before_shift: number
rpm_after_shift: number
rpm_drop: number
speed_kmh: number
}
export interface ShiftPointAnalysis {
gear_ranges: GearRpmRange[]
shift_events: ShiftEvent[]
recommended_shift_rpm: number | null
total_frames_analyzed: number
}
// Track Map Analysis (use_case = "track_map")
export interface TrackPoint {
x: number
y: number
z: number
spline: number
}
export interface TrackBoundary {
label: string
point_count: number
points: TrackPoint[]
}
export interface TrackMapAnalysis {
boundaries: TrackBoundary[]
center_line: TrackBoundary | null
total_points: number
}
// Not implemented response
export interface NotImplementedResponse {
status: 'not_implemented'
use_case: string
}
// Union of all analysis result types — matches backend discriminant responses
export type AnalysisResult = ShiftPointAnalysis | TrackMapAnalysis | NotImplementedResponse

279
src/views/DashboardView.vue Normal file
View 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
View 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>

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

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

26
tsconfig.app.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

21
tsconfig.node.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["node"]
},
"include": ["vite.config.*"]
}

22
vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
}
}
}
})