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
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
57
AGENTS.md
Normal 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
174
README.md
Normal 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
6
env.d.ts
vendored
Normal 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
13
index.html
Normal 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
1892
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal 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
0
public/favicon.ico
Normal file
115
src/App.vue
Normal file
115
src/App.vue
Normal 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
118
src/api/client.ts
Normal 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
268
src/assets/main.css
Normal 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);
|
||||
}
|
||||
169
src/components/FeedStatus.vue
Normal file
169
src/components/FeedStatus.vue
Normal 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
10
src/main.ts
Normal 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
42
src/router/index.ts
Normal 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
136
src/stores/sessions.ts
Normal 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
89
src/stores/telemetry.ts
Normal 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
170
src/types/index.ts
Normal 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
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>
|
||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
21
tsconfig.node.json
Normal file
21
tsconfig.node.json
Normal 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
22
vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue