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:
parent
c6bcab486c
commit
43ffedbf78
6 changed files with 392 additions and 6 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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) ──
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
300
src/views/GripAnalysisView.vue
Normal file
300
src/views/GripAnalysisView.vue
Normal 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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue