feat: align frontend with rusty-telemetry v0.5.0 API changes
- 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
This commit is contained in:
parent
aa6293d346
commit
87e5bcae5b
6 changed files with 156 additions and 114 deletions
|
|
@ -7,8 +7,8 @@ import type {
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
StartRunRequest,
|
StartRunRequest,
|
||||||
SessionRun,
|
SessionRun,
|
||||||
UseCase,
|
UseCaseInfo,
|
||||||
Game,
|
AnalysisResult,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
|
|
@ -93,25 +93,25 @@ export async function completeSession(id: string): Promise<UseCaseSession> {
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnalysis(id: string): Promise<Record<string, unknown>> {
|
export async function getAnalysis(id: string): Promise<AnalysisResult> {
|
||||||
const res = await api.get<Record<string, unknown>>(`/sessions/${id}/analysis`)
|
const res = await api.get<AnalysisResult>(`/sessions/${id}/analysis`)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reAnalyze(id: string): Promise<Record<string, unknown>> {
|
export async function reAnalyze(id: string): Promise<AnalysisResult> {
|
||||||
const res = await api.post<Record<string, unknown>>(`/sessions/${id}/analyze`)
|
const res = await api.post<AnalysisResult>(`/sessions/${id}/analyze`)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Metadata ──
|
// ── Metadata ──
|
||||||
|
|
||||||
export async function getUseCases(): Promise<UseCase[]> {
|
export async function getUseCases(): Promise<UseCaseInfo[]> {
|
||||||
const res = await api.get<UseCase[]>('/use-cases')
|
const res = await api.get<UseCaseInfo[]>('/use-cases')
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGames(): Promise<Game[]> {
|
export async function getGames(): Promise<string[]> {
|
||||||
const res = await api.get<Game[]>('/games')
|
const res = await api.get<string[]>('/games')
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { UseCaseSession, CreateSessionRequest, StartRunRequest, SessionRun } from '@/types'
|
import type { UseCaseSession, CreateSessionRequest, StartRunRequest, SessionRun, AnalysisResult } from '@/types'
|
||||||
import * as api from '@/api/client'
|
import * as api from '@/api/client'
|
||||||
|
|
||||||
export const useSessionsStore = defineStore('sessions', () => {
|
export const useSessionsStore = defineStore('sessions', () => {
|
||||||
|
|
@ -110,7 +110,7 @@ export const useSessionsStore = defineStore('sessions', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reAnalyze(id: string): Promise<Record<string, unknown> | null> {
|
async function reAnalyze(id: string): Promise<AnalysisResult | null> {
|
||||||
try {
|
try {
|
||||||
return await api.reAnalyze(id)
|
return await api.reAnalyze(id)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,8 @@ export interface UseCaseSession {
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
runs: SessionRun[]
|
runs: SessionRun[]
|
||||||
analysis: AnalysisResult | null
|
analysis: AnalysisResult | null
|
||||||
|
guidance: string | null
|
||||||
|
recording_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSessionRequest {
|
export interface CreateSessionRequest {
|
||||||
|
|
@ -112,31 +114,49 @@ export interface StartRunRequest {
|
||||||
label?: string | null
|
label?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Use Cases ──
|
||||||
|
|
||||||
|
export interface UseCaseInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
guidance: string
|
||||||
|
}
|
||||||
|
|
||||||
// ── Analysis Results ──
|
// ── Analysis Results ──
|
||||||
|
|
||||||
// Shift Point Analysis (use_case = "shift_points")
|
// Shift Point Analysis (use_case = "shift_points")
|
||||||
export interface GearRpmRange {
|
export interface GearDataPoint {
|
||||||
|
speed_kmh: number
|
||||||
|
rpm: number
|
||||||
|
acceleration_g: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GearCurve {
|
||||||
gear: number
|
gear: number
|
||||||
gear_display: string
|
gear_display: string
|
||||||
|
run_label: string
|
||||||
|
data_points: GearDataPoint[]
|
||||||
min_rpm: number
|
min_rpm: number
|
||||||
max_rpm: number
|
max_rpm: number
|
||||||
avg_rpm: number
|
min_speed_kmh: number
|
||||||
|
max_speed_kmh: number
|
||||||
sample_count: number
|
sample_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftEvent {
|
export interface CrossoverPoint {
|
||||||
from_gear: number
|
from_gear: number
|
||||||
to_gear: number
|
to_gear: number
|
||||||
rpm_before_shift: number
|
from_gear_display: string
|
||||||
rpm_after_shift: number
|
to_gear_display: string
|
||||||
rpm_drop: number
|
crossover_speed_kmh: number
|
||||||
speed_kmh: number
|
crossover_rpm: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftPointAnalysis {
|
export interface ShiftPointAnalysis {
|
||||||
gear_ranges: GearRpmRange[]
|
gear_curves: GearCurve[]
|
||||||
shift_events: ShiftEvent[]
|
crossover_points: CrossoverPoint[]
|
||||||
recommended_shift_rpm: number | null
|
recommended_shift_rpm: number | null
|
||||||
|
runs_analyzed: number
|
||||||
total_frames_analyzed: number
|
total_frames_analyzed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
24
src/utils/analysis.ts
Normal file
24
src/utils/analysis.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime type guard: checks if an unknown value is a ShiftPointAnalysis.
|
||||||
|
* Uses the 'gear_curves' discriminator field that the backend includes.
|
||||||
|
*/
|
||||||
|
export function isShiftPointAnalysis(data: unknown): data is ShiftPointAnalysis {
|
||||||
|
return typeof data === 'object' && data !== null && 'gear_curves' in data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime type guard: checks if an unknown value is a TrackMapAnalysis.
|
||||||
|
* Uses the 'boundaries' discriminator field that the backend includes.
|
||||||
|
*/
|
||||||
|
export function isTrackMapAnalysis(data: unknown): data is TrackMapAnalysis {
|
||||||
|
return typeof data === 'object' && data !== null && 'boundaries' in data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime type guard: checks if an unknown value is a NotImplementedResponse.
|
||||||
|
*/
|
||||||
|
export function isNotImplemented(data: unknown): data is { status: 'not_implemented'; use_case: string } {
|
||||||
|
return typeof data === 'object' && data !== null && 'status' in data && (data as { status: string }).status === 'not_implemented'
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useSessionsStore } from '@/stores/sessions'
|
import { useSessionsStore } from '@/stores/sessions'
|
||||||
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types'
|
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types'
|
||||||
|
import { isShiftPointAnalysis, isTrackMapAnalysis } from '@/utils/analysis'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -34,19 +35,16 @@ const analysisData = computed(() => {
|
||||||
|
|
||||||
const shiftAnalysis = computed((): ShiftPointAnalysis | null => {
|
const shiftAnalysis = computed((): ShiftPointAnalysis | null => {
|
||||||
if (!analysisData.value) return null
|
if (!analysisData.value) return null
|
||||||
// The analysis field is the full analysis object
|
if (isShiftPointAnalysis(analysisData.value)) {
|
||||||
const data = analysisData.value
|
return analysisData.value
|
||||||
if (data && typeof data === 'object' && 'gear_ranges' in data) {
|
|
||||||
return data as unknown as ShiftPointAnalysis
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const trackAnalysis = computed((): TrackMapAnalysis | null => {
|
const trackAnalysis = computed((): TrackMapAnalysis | null => {
|
||||||
if (!analysisData.value) return null
|
if (!analysisData.value) return null
|
||||||
const data = analysisData.value
|
if (isTrackMapAnalysis(analysisData.value)) {
|
||||||
if (data && typeof data === 'object' && 'boundaries' in data) {
|
return analysisData.value
|
||||||
return data as unknown as TrackMapAnalysis
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
@ -147,6 +145,10 @@ async function handleDelete() {
|
||||||
<span class="info-label">Completed</span>
|
<span class="info-label">Completed</span>
|
||||||
<span>{{ formatTime(session.completed_at) }}</span>
|
<span>{{ formatTime(session.completed_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="session.guidance" class="info-item" style="grid-column: 1 / -1">
|
||||||
|
<span class="info-label">Guidance</span>
|
||||||
|
<span class="text-secondary" style="font-size: 0.85rem">{{ session.guidance }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -243,14 +245,18 @@ async function handleDelete() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick stats for shift points -->
|
<!-- Quick stats for shift points -->
|
||||||
<div v-if="shiftAnalysis" class="analysis-stats grid grid-2 mb-2">
|
<div v-if="shiftAnalysis" class="analysis-stats grid grid-3 mb-2">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span class="stat-label">Recommended Shift RPM</span>
|
<span class="stat-label">Recommended Shift RPM</span>
|
||||||
<span class="stat-value mono">{{ Math.round(shiftAnalysis.recommended_shift_rpm ?? 0) }}</span>
|
<span class="stat-value mono">{{ Math.round(shiftAnalysis.recommended_shift_rpm ?? 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span class="stat-label">Shift Events Detected</span>
|
<span class="stat-label">Crossover Points</span>
|
||||||
<span class="stat-value mono">{{ shiftAnalysis.shift_events.length }}</span>
|
<span class="stat-value mono">{{ shiftAnalysis.crossover_points.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Runs Analyzed</span>
|
||||||
|
<span class="stat-value mono">{{ shiftAnalysis.runs_analyzed }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Bar, Line } from 'vue-chartjs'
|
import { Bar, Scatter } from 'vue-chartjs'
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Filler,
|
Filler,
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import type { ShiftPointAnalysis } from '@/types'
|
import type { ShiftPointAnalysis } from '@/types'
|
||||||
|
import { isShiftPointAnalysis } from '@/utils/analysis'
|
||||||
import * as api from '@/api/client'
|
import * as api from '@/api/client'
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler)
|
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler)
|
||||||
|
|
@ -30,8 +31,8 @@ const error = ref<string | null>(null)
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getAnalysis(sessionId.value)
|
const data = await api.getAnalysis(sessionId.value)
|
||||||
if (data && 'gear_ranges' in data) {
|
if (isShiftPointAnalysis(data)) {
|
||||||
analysis.value = data as unknown as ShiftPointAnalysis
|
analysis.value = data
|
||||||
} else {
|
} else {
|
||||||
error.value = 'Analysis data not available or wrong type'
|
error.value = 'Analysis data not available or wrong type'
|
||||||
}
|
}
|
||||||
|
|
@ -42,95 +43,86 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Gear RPM range chart data
|
const gearCurvesChartData = computed(() => {
|
||||||
const gearRangeChartData = computed(() => {
|
|
||||||
if (!analysis.value) return null
|
if (!analysis.value) return null
|
||||||
const ranges = analysis.value.gear_ranges.filter((g) => g.gear >= 1)
|
const curves = analysis.value.gear_curves.filter((g) => g.gear >= 2)
|
||||||
|
const colors = ['#4f8ff7', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#60a5fa', '#f472b6']
|
||||||
return {
|
return {
|
||||||
labels: ranges.map((g) => g.gear_display),
|
datasets: curves.map((curve, i) => ({
|
||||||
datasets: [
|
label: `${curve.gear_display} (${curve.run_label})`,
|
||||||
{
|
data: curve.data_points.map((p) => ({ x: p.speed_kmh, y: p.acceleration_g })),
|
||||||
label: 'Min RPM',
|
borderColor: colors[i % colors.length],
|
||||||
data: ranges.map((g) => g.min_rpm),
|
backgroundColor: colors[i % colors.length] + '20',
|
||||||
backgroundColor: 'rgba(79, 143, 247, 0.3)',
|
showLine: true,
|
||||||
borderColor: 'rgba(79, 143, 247, 0.8)',
|
fill: false,
|
||||||
borderWidth: 1,
|
tension: 0.3,
|
||||||
},
|
pointRadius: 0,
|
||||||
{
|
borderWidth: 2,
|
||||||
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 = {
|
const gearCurvesOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { labels: { color: '#8b8fa3' } },
|
legend: { labels: { color: '#8b8fa3' } },
|
||||||
title: { display: true, text: 'Gear RPM Ranges', color: '#e2e4ea' },
|
title: { display: true, text: 'Gear Acceleration Curves', color: '#e2e4ea' },
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
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: 'RPM', color: '#8b8fa3' } },
|
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'Acceleration (g)', color: '#8b8fa3' } },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift events timeline data
|
const crossoverChartData = computed(() => {
|
||||||
const shiftEventsChartData = computed(() => {
|
if (!analysis.value || analysis.value.crossover_points.length === 0) return null
|
||||||
if (!analysis.value || analysis.value.shift_events.length === 0) return null
|
const points = analysis.value.crossover_points
|
||||||
const events = analysis.value.shift_events
|
|
||||||
return {
|
return {
|
||||||
labels: events.map((_, i) => `#${i + 1}`),
|
labels: points.map((p) => `${p.from_gear_display} → ${p.to_gear_display}`),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'RPM Before Shift',
|
label: 'Crossover RPM',
|
||||||
data: events.map((e) => e.rpm_before_shift),
|
data: points.map((p) => p.crossover_rpm),
|
||||||
borderColor: 'rgba(248, 113, 113, 1)',
|
backgroundColor: 'rgba(79, 143, 247, 0.5)',
|
||||||
backgroundColor: 'rgba(248, 113, 113, 0.1)',
|
borderColor: 'rgba(79, 143, 247, 1)',
|
||||||
fill: false,
|
borderWidth: 1,
|
||||||
tension: 0.2,
|
yAxisID: 'y',
|
||||||
pointRadius: 4,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'RPM After Shift',
|
label: 'Crossover Speed (km/h)',
|
||||||
data: events.map((e) => e.rpm_after_shift),
|
data: points.map((p) => p.crossover_speed_kmh),
|
||||||
|
backgroundColor: 'rgba(52, 211, 153, 0.5)',
|
||||||
borderColor: 'rgba(52, 211, 153, 1)',
|
borderColor: 'rgba(52, 211, 153, 1)',
|
||||||
backgroundColor: 'rgba(52, 211, 153, 0.1)',
|
borderWidth: 1,
|
||||||
fill: false,
|
yAxisID: 'y1',
|
||||||
tension: 0.2,
|
|
||||||
pointRadius: 4,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const shiftEventsOptions = {
|
const crossoverChartOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { labels: { color: '#8b8fa3' } },
|
legend: { labels: { color: '#8b8fa3' } },
|
||||||
title: { display: true, text: 'Shift Events — RPM Before/After', color: '#e2e4ea' },
|
title: { display: true, text: 'Crossover Points', color: '#e2e4ea' },
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
||||||
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'RPM', color: '#8b8fa3' } },
|
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' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
|
||||||
function gearLabel(gear: number): string {
|
|
||||||
if (gear === -1) return 'R'
|
|
||||||
if (gear === 0) return 'N'
|
|
||||||
return gear.toString()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -146,14 +138,18 @@ function gearLabel(gear: number): string {
|
||||||
|
|
||||||
<template v-else-if="analysis">
|
<template v-else-if="analysis">
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-3 mb-3">
|
<div class="grid grid-4 mb-3">
|
||||||
<div class="card stat-card">
|
<div class="card stat-card">
|
||||||
<span class="stat-label">Recommended Shift RPM</span>
|
<span class="stat-label">Recommended Shift RPM</span>
|
||||||
<span class="stat-value mono">{{ Math.round(analysis.recommended_shift_rpm ?? 0) }}</span>
|
<span class="stat-value mono">{{ Math.round(analysis.recommended_shift_rpm ?? 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card stat-card">
|
<div class="card stat-card">
|
||||||
<span class="stat-label">Shift Events</span>
|
<span class="stat-label">Crossover Points</span>
|
||||||
<span class="stat-value mono">{{ analysis.shift_events.length }}</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>
|
||||||
<div class="card stat-card">
|
<div class="card stat-card">
|
||||||
<span class="stat-label">Frames Analyzed</span>
|
<span class="stat-label">Frames Analyzed</span>
|
||||||
|
|
@ -161,20 +157,20 @@ function gearLabel(gear: number): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gear RPM Ranges Chart -->
|
<!-- Gear Acceleration Curves Chart -->
|
||||||
<div v-if="gearRangeChartData" class="card mb-3">
|
<div v-if="gearCurvesChartData" class="card mb-3">
|
||||||
<Bar :data="gearRangeChartData" :options="gearRangeOptions" />
|
<Scatter :data="gearCurvesChartData" :options="gearCurvesOptions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shift Events Chart -->
|
<!-- Crossover Points Chart -->
|
||||||
<div v-if="shiftEventsChartData" class="card mb-3">
|
<div v-if="crossoverChartData" class="card mb-3">
|
||||||
<Line :data="shiftEventsChartData" :options="shiftEventsOptions" />
|
<Bar :data="crossoverChartData" :options="crossoverChartOptions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shift Events Table -->
|
<!-- Crossover Points Table -->
|
||||||
<div v-if="analysis.shift_events.length > 0" class="card">
|
<div v-if="analysis.crossover_points.length > 0" class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Shift Events Detail</h2>
|
<h2>Crossover Points</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -182,20 +178,16 @@ function gearLabel(gear: number): string {
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>From → To</th>
|
<th>From → To</th>
|
||||||
<th>RPM Before</th>
|
<th>Crossover RPM</th>
|
||||||
<th>RPM After</th>
|
<th>Crossover Speed (km/h)</th>
|
||||||
<th>RPM Drop</th>
|
|
||||||
<th>Speed (km/h)</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(event, i) in analysis.shift_events" :key="i">
|
<tr v-for="(point, i) in analysis.crossover_points" :key="i">
|
||||||
<td class="mono">{{ i + 1 }}</td>
|
<td class="mono">{{ i + 1 }}</td>
|
||||||
<td>{{ gearLabel(event.from_gear) }} → {{ gearLabel(event.to_gear) }}</td>
|
<td>{{ point.from_gear_display }} → {{ point.to_gear_display }}</td>
|
||||||
<td class="mono">{{ Math.round(event.rpm_before_shift) }}</td>
|
<td class="mono">{{ Math.round(point.crossover_rpm) }}</td>
|
||||||
<td class="mono">{{ Math.round(event.rpm_after_shift) }}</td>
|
<td class="mono">{{ point.crossover_speed_kmh.toFixed(1) }}</td>
|
||||||
<td class="mono">{{ Math.round(event.rpm_drop) }}</td>
|
|
||||||
<td class="mono">{{ Math.round(event.speed_kmh) }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue