- Update ShiftPointAnalysis types: gear_ranges → gear_curves (with acceleration data points), shift_events → crossover_points, add runs_analyzed field - Add UseCaseInfo type for GET /api/use-cases (now returns objects with id, name, guidance instead of plain strings) - Add guidance and recording_id fields to UseCaseSession - Rewrite ShiftPointsView: scatter chart for gear acceleration curves, dual y-axis bar chart for crossover RPM/speed, 4-column summary - Update SessionDetailView: show session guidance, crossover points and runs analyzed in analysis stats - Extract shared type guard functions into src/utils/analysis.ts - Type getAnalysis/reAnalyze as AnalysisResult instead of Record<string, unknown> - Type getGames return as string[] to match backend
254 lines
7.3 KiB
Vue
254 lines
7.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { Bar, Scatter } 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 { isShiftPointAnalysis } from '@/utils/analysis'
|
|
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 (isShiftPointAnalysis(data)) {
|
|
analysis.value = data
|
|
} 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
|
|
}
|
|
})
|
|
|
|
const gearCurvesChartData = computed(() => {
|
|
if (!analysis.value) return null
|
|
const curves = analysis.value.gear_curves.filter((g) => g.gear >= 2)
|
|
const colors = ['#4f8ff7', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#60a5fa', '#f472b6']
|
|
return {
|
|
datasets: curves.map((curve, i) => ({
|
|
label: `${curve.gear_display} (${curve.run_label})`,
|
|
data: curve.data_points.map((p) => ({ x: p.speed_kmh, y: p.acceleration_g })),
|
|
borderColor: colors[i % colors.length],
|
|
backgroundColor: colors[i % colors.length] + '20',
|
|
showLine: true,
|
|
fill: false,
|
|
tension: 0.3,
|
|
pointRadius: 0,
|
|
borderWidth: 2,
|
|
})),
|
|
}
|
|
})
|
|
|
|
const gearCurvesOptions = {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { labels: { color: '#8b8fa3' } },
|
|
title: { display: true, text: 'Gear Acceleration Curves', color: '#e2e4ea' },
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'Speed (km/h)', color: '#8b8fa3' } },
|
|
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'Acceleration (g)', color: '#8b8fa3' } },
|
|
},
|
|
}
|
|
|
|
const crossoverChartData = computed(() => {
|
|
if (!analysis.value || analysis.value.crossover_points.length === 0) return null
|
|
const points = analysis.value.crossover_points
|
|
return {
|
|
labels: points.map((p) => `${p.from_gear_display} → ${p.to_gear_display}`),
|
|
datasets: [
|
|
{
|
|
label: 'Crossover RPM',
|
|
data: points.map((p) => p.crossover_rpm),
|
|
backgroundColor: 'rgba(79, 143, 247, 0.5)',
|
|
borderColor: 'rgba(79, 143, 247, 1)',
|
|
borderWidth: 1,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Crossover Speed (km/h)',
|
|
data: points.map((p) => p.crossover_speed_kmh),
|
|
backgroundColor: 'rgba(52, 211, 153, 0.5)',
|
|
borderColor: 'rgba(52, 211, 153, 1)',
|
|
borderWidth: 1,
|
|
yAxisID: 'y1',
|
|
},
|
|
],
|
|
}
|
|
})
|
|
|
|
const crossoverChartOptions = {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { labels: { color: '#8b8fa3' } },
|
|
title: { display: true, text: 'Crossover Points', color: '#e2e4ea' },
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
|
y: {
|
|
type: 'linear' as const,
|
|
position: 'left' as const,
|
|
ticks: { color: '#4f8ff7' },
|
|
grid: { color: '#2a2e3f' },
|
|
title: { display: true, text: 'RPM', color: '#4f8ff7' },
|
|
},
|
|
y1: {
|
|
type: 'linear' as const,
|
|
position: 'right' as const,
|
|
ticks: { color: '#34d399' },
|
|
grid: { drawOnChartArea: false },
|
|
title: { display: true, text: 'Speed (km/h)', color: '#34d399' },
|
|
},
|
|
},
|
|
}
|
|
</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-4 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">Crossover Points</span>
|
|
<span class="stat-value mono">{{ analysis.crossover_points.length }}</span>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<span class="stat-label">Runs Analyzed</span>
|
|
<span class="stat-value mono">{{ analysis.runs_analyzed }}</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 Acceleration Curves Chart -->
|
|
<div v-if="gearCurvesChartData" class="card mb-3">
|
|
<Scatter :data="gearCurvesChartData" :options="gearCurvesOptions" />
|
|
</div>
|
|
|
|
<!-- Crossover Points Chart -->
|
|
<div v-if="crossoverChartData" class="card mb-3">
|
|
<Bar :data="crossoverChartData" :options="crossoverChartOptions" />
|
|
</div>
|
|
|
|
<!-- Crossover Points Table -->
|
|
<div v-if="analysis.crossover_points.length > 0" class="card">
|
|
<div class="card-header">
|
|
<h2>Crossover Points</h2>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>From → To</th>
|
|
<th>Crossover RPM</th>
|
|
<th>Crossover Speed (km/h)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(point, i) in analysis.crossover_points" :key="i">
|
|
<td class="mono">{{ i + 1 }}</td>
|
|
<td>{{ point.from_gear_display }} → {{ point.to_gear_display }}</td>
|
|
<td class="mono">{{ Math.round(point.crossover_rpm) }}</td>
|
|
<td class="mono">{{ point.crossover_speed_kmh.toFixed(1) }}</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>
|