feat: GripAnalysisView — car grip envelope visualization

- New GripAnalysisView with 4 scatter charts:
  1. Grip Envelope (peak G vs speed: lateral/accel/brake)
  2. Grip Circle (longitudinal vs lateral G scatter)
  3. Cornering Grip vs Speed
  4. Steering Response (steer angle vs lateral G)
- Summary stats: max lateral/accel/brake/combined G, peak lateral speed
- GripAnalysis types + type guard
- car_grip use case in SessionDetailView (quick stats + view button)
- Route /analysis/grip/:id
- Global .grid-4 CSS utility
This commit is contained in:
Jan-Peter von Hunnius 2026-06-24 10:18:23 +02:00
parent c6bcab486c
commit 43ffedbf78
6 changed files with 392 additions and 6 deletions

View file

@ -196,8 +196,12 @@ input, select, textarea {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.grid-4 {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.grid-2, .grid-3 { .grid-2, .grid-3, .grid-4 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View file

@ -30,6 +30,12 @@ const router = createRouter({
component: () => import('@/views/ShiftPointsView.vue'), component: () => import('@/views/ShiftPointsView.vue'),
props: true, props: true,
}, },
{
path: '/analysis/grip/:id',
name: 'grip-analysis',
component: () => import('@/views/GripAnalysisView.vue'),
props: true,
},
{ {
path: '/tracks', path: '/tracks',
name: 'tracks', name: 'tracks',

View file

@ -5,6 +5,7 @@
export type UseCase = export type UseCase =
| 'shift_points' | 'shift_points'
| 'car_grip'
| 'racing_line' | 'racing_line'
| 'braking_analysis' | 'braking_analysis'
| 'lap_times' | 'lap_times'
@ -161,6 +162,43 @@ export interface ShiftPointAnalysis {
total_frames_analyzed: number total_frames_analyzed: number
} }
// Car Grip Analysis (use_case = "car_grip")
export interface GripEnvelopePoint {
speed_kmh: number
max_lateral_g: number
max_accel_g: number
max_brake_g: number
sample_count: number
}
export interface GripCirclePoint {
longitudinal_g: number
lateral_g: number
}
export interface SteeringResponsePoint {
steer: number
lateral_g: number
speed_kmh: number
}
export interface GripSummary {
max_lateral_g: number
max_accel_g: number
max_brake_g: number
max_combined_g: number
peak_lateral_speed_kmh: number
}
export interface CarGripAnalysis {
summary: GripSummary
grip_envelope: GripEnvelopePoint[]
grip_circle: GripCirclePoint[]
steering_response: SteeringResponsePoint[]
runs_analyzed: number
total_frames_analyzed: number
}
// Track Map Analysis (use_case = "track_mapping") // Track Map Analysis (use_case = "track_mapping")
export interface TrackPoint { export interface TrackPoint {
x: number x: number
@ -237,7 +275,7 @@ export interface BuildTrackModelRequest {
export type TrackMappingAnalysis = TrackMapAnalysis export type TrackMappingAnalysis = TrackMapAnalysis
export type AnalysisResult = ShiftPointAnalysis | TrackMapAnalysis | NotImplementedResponse export type AnalysisResult = ShiftPointAnalysis | CarGripAnalysis | TrackMapAnalysis | NotImplementedResponse
// ── Characteristics (aggregated across sessions) ── // ── Characteristics (aggregated across sessions) ──

View file

@ -1,4 +1,4 @@
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types' import type { ShiftPointAnalysis, CarGripAnalysis, TrackMapAnalysis } from '@/types'
/** /**
* Runtime type guard: checks if an unknown value is a ShiftPointAnalysis. * Runtime type guard: checks if an unknown value is a ShiftPointAnalysis.
@ -8,6 +8,14 @@ export function isShiftPointAnalysis(data: unknown): data is ShiftPointAnalysis
return typeof data === 'object' && data !== null && 'gear_curves' in data return typeof data === 'object' && data !== null && 'gear_curves' in data
} }
/**
* Runtime type guard: checks if an unknown value is a CarGripAnalysis.
* Uses the 'grip_envelope' discriminator field.
*/
export function isCarGripAnalysis(data: unknown): data is CarGripAnalysis {
return typeof data === 'object' && data !== null && 'grip_envelope' in data
}
/** /**
* Runtime type guard: checks if an unknown value is a TrackMapAnalysis. * Runtime type guard: checks if an unknown value is a TrackMapAnalysis.
* Uses the 'boundaries' discriminator field that the backend includes. * Uses the 'boundaries' discriminator field that the backend includes.

View file

@ -0,0 +1,300 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Scatter } from 'vue-chartjs'
import {
Chart as ChartJS,
LinearScale,
PointElement,
Title,
Tooltip,
Legend,
} from 'chart.js'
import type { CarGripAnalysis } from '@/types'
import { isCarGripAnalysis } from '@/utils/analysis'
import * as api from '@/api/client'
ChartJS.register(LinearScale, PointElement, Title, Tooltip, Legend)
const route = useRoute()
const router = useRouter()
const sessionId = computed(() => route.params.id as string)
const analysis = ref<CarGripAnalysis | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
try {
const data = await api.getAnalysis(sessionId.value)
if (isCarGripAnalysis(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 axisColor = '#8b8fa3'
const gridColor = '#2a2e3f'
const gripCurveData = computed(() => {
if (!analysis.value) return null
const env = analysis.value.grip_envelope
return {
datasets: [
{
label: 'Lateral G',
data: env.map((p) => ({ x: p.speed_kmh, y: p.max_lateral_g })),
borderColor: '#4f8ff7',
backgroundColor: '#4f8ff730',
showLine: true,
fill: false,
pointRadius: 2,
borderWidth: 2,
tension: 0.3,
},
{
label: 'Accel G',
data: env.map((p) => ({ x: p.speed_kmh, y: p.max_accel_g })),
borderColor: '#34d399',
backgroundColor: '#34d39930',
showLine: true,
fill: false,
pointRadius: 2,
borderWidth: 2,
tension: 0.3,
},
{
label: 'Brake G',
data: env.map((p) => ({ x: p.speed_kmh, y: -p.max_brake_g })),
borderColor: '#f87171',
backgroundColor: '#f8717130',
showLine: true,
fill: false,
pointRadius: 2,
borderWidth: 2,
tension: 0.3,
},
],
}
})
const gripCurveOptions = {
responsive: true,
plugins: {
legend: { labels: { color: axisColor } },
title: { display: true, text: 'Grip Envelope — Peak G vs Speed', color: '#e2e4ea' },
},
scales: {
x: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'Speed (km/h)', color: axisColor } },
y: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'G-force', color: axisColor } },
},
}
const gripCircleData = computed(() => {
if (!analysis.value) return null
return {
datasets: [
{
label: 'G-force samples',
data: analysis.value.grip_circle.map((p) => ({ x: p.longitudinal_g, y: p.lateral_g })),
backgroundColor: '#4f8ff720',
borderColor: '#4f8ff7',
pointRadius: 1.5,
showLine: false,
},
],
}
})
const gripCircleOptions = {
responsive: true,
plugins: {
legend: { display: false },
title: { display: true, text: 'Grip Circle — Longitudinal vs Lateral G', color: '#e2e4ea' },
},
scales: {
x: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'Longitudinal G (brake ← → accel)', color: axisColor } },
y: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'Lateral G (right ← → left)', color: axisColor } },
},
}
const steerResponseData = computed(() => {
if (!analysis.value) return null
return {
datasets: [
{
label: 'Samples',
data: analysis.value.steering_response.map((p) => ({ x: p.steer, y: p.lateral_g })),
backgroundColor: '#fbbf2420',
borderColor: '#fbbf24',
pointRadius: 1.5,
showLine: false,
},
],
}
})
const steerResponseOptions = {
responsive: true,
plugins: {
legend: { display: false },
title: { display: true, text: 'Steering Response — Steer Angle vs Lateral G', color: '#e2e4ea' },
},
scales: {
x: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'Steering Input (right ← → left)', color: axisColor } },
y: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'Lateral G', color: axisColor } },
},
}
const speedVsSteerData = computed(() => {
if (!analysis.value) return null
const env = analysis.value.grip_envelope
return {
datasets: [
{
label: 'Peak lateral G per speed bin',
data: env.map((p) => ({ x: p.speed_kmh, y: p.max_lateral_g })),
borderColor: '#a78bfa',
backgroundColor: '#a78bfa30',
showLine: true,
fill: false,
pointRadius: 2,
borderWidth: 2,
tension: 0.3,
},
],
}
})
const speedVsSteerOptions = {
responsive: true,
plugins: {
legend: { labels: { color: axisColor } },
title: { display: true, text: 'Cornering Grip vs Speed', color: '#e2e4ea' },
},
scales: {
x: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'Speed (km/h)', color: axisColor } },
y: { ticks: { color: axisColor }, grid: { color: gridColor }, title: { display: true, text: 'Peak Lateral G', color: axisColor } },
},
}
</script>
<template>
<div class="grip-analysis">
<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">Car Grip Analysis</h2>
</div>
<div v-if="loading" class="card text-secondary">Loading analysis...</div>
<div v-if="error" class="card text-error">{{ error }}</div>
<template v-if="analysis">
<!-- Summary Stats -->
<div class="stat-grid mb-3">
<div class="stat-card">
<span class="stat-label">Max Lateral G</span>
<span class="stat-value mono">{{ analysis.summary.max_lateral_g.toFixed(2) }}</span>
<span class="stat-sub">at {{ Math.round(analysis.summary.peak_lateral_speed_kmh) }} km/h</span>
</div>
<div class="stat-card">
<span class="stat-label">Max Accel G</span>
<span class="stat-value mono">{{ analysis.summary.max_accel_g.toFixed(2) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Max Brake G</span>
<span class="stat-value mono">{{ analysis.summary.max_brake_g.toFixed(2) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Max Combined G</span>
<span class="stat-value mono">{{ analysis.summary.max_combined_g.toFixed(2) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Frames Analyzed</span>
<span class="stat-value mono">{{ analysis.total_frames_analyzed.toLocaleString() }}</span>
</div>
</div>
<!-- Charts -->
<div class="chart-grid">
<div class="chart-card">
<Scatter v-if="gripCurveData" :data="gripCurveData" :options="gripCurveOptions" />
</div>
<div class="chart-card">
<Scatter v-if="gripCircleData" :data="gripCircleData" :options="gripCircleOptions" />
</div>
<div class="chart-card">
<Scatter v-if="speedVsSteerData" :data="speedVsSteerData" :options="speedVsSteerOptions" />
</div>
<div class="chart-card">
<Scatter v-if="steerResponseData" :data="steerResponseData" :options="steerResponseOptions" />
</div>
</div>
</template>
</div>
</template>
<style scoped>
.page-title {
font-size: 1.5rem;
margin: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.75rem;
background: var(--color-background);
border-radius: var(--radius-sm);
}
.stat-label {
font-size: 0.7rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.stat-sub {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 900px) {
.chart-grid {
grid-template-columns: 1fr;
}
}
.chart-card {
padding: 1rem;
background: var(--color-surface);
border-radius: var(--radius);
border: 1px solid var(--color-border);
}
</style>

View file

@ -2,8 +2,8 @@
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 { useSessionsStore } from '@/stores/sessions' import { useSessionsStore } from '@/stores/sessions'
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types' import type { ShiftPointAnalysis, CarGripAnalysis, TrackMapAnalysis } from '@/types'
import { isShiftPointAnalysis, isTrackMapAnalysis } from '@/utils/analysis' import { isShiftPointAnalysis, isCarGripAnalysis, isTrackMapAnalysis } from '@/utils/analysis'
import * as api from '@/api/client' import * as api from '@/api/client'
const props = defineProps<{ const props = defineProps<{
@ -56,6 +56,14 @@ const trackAnalysis = computed((): TrackMapAnalysis | null => {
return null return null
}) })
const gripAnalysis = computed((): CarGripAnalysis | null => {
if (!analysisData.value) return null
if (isCarGripAnalysis(analysisData.value)) {
return analysisData.value
}
return null
})
onMounted(async () => { onMounted(async () => {
await store.fetchSession(sessionId.value) await store.fetchSession(sessionId.value)
}) })
@ -101,6 +109,8 @@ function viewAnalysis() {
if (!session.value) return if (!session.value) return
if (session.value.use_case === 'shift_points') { if (session.value.use_case === 'shift_points') {
router.push({ name: 'shift-points', params: { id: session.value.id } }) router.push({ name: 'shift-points', params: { id: session.value.id } })
} else if (session.value.use_case === 'car_grip') {
router.push({ name: 'grip-analysis', params: { id: session.value.id } })
} }
} }
@ -339,8 +349,28 @@ async function handleBuildTrack() {
</div> </div>
</div> </div>
<!-- Quick stats for car grip -->
<div v-if="gripAnalysis" class="analysis-stats grid grid-4 mb-2">
<div class="stat-card">
<span class="stat-label">Max Lateral G</span>
<span class="stat-value mono">{{ gripAnalysis.summary.max_lateral_g.toFixed(2) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Max Accel G</span>
<span class="stat-value mono">{{ gripAnalysis.summary.max_accel_g.toFixed(2) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Max Brake G</span>
<span class="stat-value mono">{{ gripAnalysis.summary.max_brake_g.toFixed(2) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Max Combined G</span>
<span class="stat-value mono">{{ gripAnalysis.summary.max_combined_g.toFixed(2) }}</span>
</div>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button v-if="session.use_case === 'shift_points'" class="btn btn-primary" @click="viewAnalysis"> <button v-if="session.use_case === 'shift_points' || session.use_case === 'car_grip'" class="btn btn-primary" @click="viewAnalysis">
📊 View Full Analysis 📊 View Full Analysis
</button> </button>
<button class="btn btn-secondary" @click="handleReAnalyze"> <button class="btn btn-secondary" @click="handleReAnalyze">