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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue