racecraft/src/views/SessionDetailView.vue
Jan-Peter v. Hunnius 43ffedbf78 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
2026-06-24 10:18:23 +02:00

545 lines
17 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionsStore } from '@/stores/sessions'
import type { ShiftPointAnalysis, CarGripAnalysis, TrackMapAnalysis } from '@/types'
import { isShiftPointAnalysis, isCarGripAnalysis, isTrackMapAnalysis } from '@/utils/analysis'
import * as api from '@/api/client'
const props = defineProps<{
id: string
}>()
const route = useRoute()
const router = useRouter()
const store = useSessionsStore()
const showStartRun = ref(false)
const runFeedName = ref('ksudp')
const runLabel = ref('')
const runMergeEnabled = ref(false)
const runMergeFeeds = ref<string[]>([])
const sessionId = computed(() => props.id ?? (route.params.id as string))
const session = computed(() => store.currentSession)
const feedOptions = ['ksudp', 'telemetry_tool', 'pcars']
const mergeFeedOptions = computed(() => {
return feedOptions.filter((f) => f !== runFeedName.value)
})
const hasActiveRun = computed(() => {
return session.value?.runs.some((r) => r.status === 'recording') ?? false
})
const isCompleted = computed(() => session.value?.status === 'completed')
const analysisData = computed(() => {
if (!session.value?.analysis) return null
return session.value.analysis as Record<string, unknown>
})
const shiftAnalysis = computed((): ShiftPointAnalysis | null => {
if (!analysisData.value) return null
if (isShiftPointAnalysis(analysisData.value)) {
return analysisData.value
}
return null
})
const trackAnalysis = computed((): TrackMapAnalysis | null => {
if (!analysisData.value) return null
if (isTrackMapAnalysis(analysisData.value)) {
return analysisData.value
}
return null
})
const gripAnalysis = computed((): CarGripAnalysis | null => {
if (!analysisData.value) return null
if (isCarGripAnalysis(analysisData.value)) {
return analysisData.value
}
return null
})
onMounted(async () => {
await store.fetchSession(sessionId.value)
})
function formatTime(iso: string): string {
return new Date(iso).toLocaleString()
}
async function handleStartRun() {
const run = await store.startRun(sessionId.value, {
feed_name: runFeedName.value,
label: runLabel.value || undefined,
merge_feeds: runMergeEnabled.value && runMergeFeeds.value.length > 0 ? runMergeFeeds.value : undefined,
})
if (run) {
showStartRun.value = false
runLabel.value = ''
runMergeEnabled.value = false
runMergeFeeds.value = []
}
}
async function handleStopRun(runId: string) {
await store.stopRun(sessionId.value, runId)
}
async function handleComplete() {
const result = await store.completeSession(sessionId.value)
if (result) {
// Refresh to get analysis
await store.fetchSession(sessionId.value)
}
}
async function handleReAnalyze() {
const result = await store.reAnalyze(sessionId.value)
if (result !== null) {
await store.fetchSession(sessionId.value)
}
}
function viewAnalysis() {
if (!session.value) return
if (session.value.use_case === 'shift_points') {
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 } })
}
}
async function handleDelete() {
if (confirm('Delete this session?')) {
await store.deleteSession(sessionId.value)
router.push('/sessions')
}
}
async function handleDeleteRun(runId: string, runLabel: string) {
if (confirm(`Delete run "${runLabel || 'Untitled Run'}"?`)) {
await store.deleteRun(sessionId.value, runId)
}
}
async function handleReopen() {
await store.reopenSession(sessionId.value)
}
const buildingTrack = ref(false)
const buildTrackError = ref<string | null>(null)
const buildTrackSuccess = ref(false)
const isTrackMapping = computed(() => session.value?.use_case === 'track_mapping')
const hasLeftRun = computed(() =>
session.value?.runs.some((r) => r.label?.toLowerCase() === 'left' && r.status === 'stopped') ?? false,
)
const hasRightRun = computed(() =>
session.value?.runs.some((r) => r.label?.toLowerCase() === 'right' && r.status === 'stopped') ?? false,
)
const canBuildTrack = computed(() => isCompleted.value && hasLeftRun.value && hasRightRun.value)
async function handleBuildTrack() {
buildingTrack.value = true
buildTrackError.value = null
buildTrackSuccess.value = false
try {
await store.reAnalyze(sessionId.value)
await api.buildTrackModel({ session_id: sessionId.value })
buildTrackSuccess.value = true
} catch (e: unknown) {
buildTrackError.value = e instanceof Error ? e.message : 'Failed to build track model'
} finally {
buildingTrack.value = false
}
}
</script>
<template>
<div class="session-detail">
<div class="flex items-center gap-2 mb-3">
<button class="btn btn-secondary btn-sm" @click="router.push('/sessions')">← Back</button>
<h2 class="page-title">
{{ session?.label || 'Session' }}
</h2>
<span v-if="session" :class="['badge', session.status === 'active' ? 'badge-primary' : 'badge-muted']">
{{ session.status }}
</span>
</div>
<div v-if="store.error" class="card mb-3 text-error">{{ store.error }}</div>
<div v-if="store.loading && !session" class="card text-secondary">Loading...</div>
<template v-if="session">
<!-- Session Info -->
<section class="card mb-3">
<div class="card-header">
<h2>Session Info</h2>
<div class="flex gap-1">
<button v-if="!isCompleted" class="btn btn-danger btn-sm" @click="handleDelete">Delete</button>
</div>
</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Use Case</span>
<span>{{ session.use_case.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }}</span>
</div>
<div class="info-item">
<span class="info-label">Created</span>
<span>{{ formatTime(session.created_at) }}</span>
</div>
<div class="info-item">
<span class="info-label">Runs</span>
<span>{{ session.runs.length }}</span>
</div>
<div v-if="session.completed_at" class="info-item">
<span class="info-label">Completed</span>
<span>{{ formatTime(session.completed_at) }}</span>
</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>
</section>
<!-- Runs -->
<section class="card mb-3">
<div class="card-header">
<h2>Runs</h2>
<div class="flex gap-1">
<button
v-if="!isCompleted && !hasActiveRun"
class="btn btn-primary btn-sm"
@click="showStartRun = !showStartRun"
>
{{ showStartRun ? 'Cancel' : '+ Start Run' }}
</button>
</div>
</div>
<!-- Start Run Form -->
<div v-if="showStartRun" class="run-form mb-2">
<div class="flex gap-2 items-center">
<select v-model="runFeedName" class="form-select">
<option v-for="f in feedOptions" :key="f" :value="f">{{ f }}</option>
</select>
<input
v-model="runLabel"
placeholder="Label (e.g. 'left', 'right')"
class="form-input"
@keyup.enter="handleStartRun"
/>
<button class="btn btn-primary btn-sm" @click="handleStartRun">Start</button>
</div>
<div class="flex items-center gap-2 mt-2">
<label class="merge-toggle">
<input type="checkbox" v-model="runMergeEnabled" />
<span>Merge with additional feeds</span>
</label>
</div>
<div v-if="runMergeEnabled" class="flex gap-2 items-center mt-1">
<label v-for="f in mergeFeedOptions" :key="f" class="merge-feed-option">
<input type="checkbox" :value="f" v-model="runMergeFeeds" />
<span>{{ f }}</span>
</label>
</div>
</div>
<div v-if="session.runs.length === 0" class="text-secondary text-muted">
No runs yet. Start a run to capture telemetry data.
</div>
<div v-else class="run-list">
<div v-for="run in session.runs" :key="run.id" class="run-item">
<div class="flex items-center gap-2">
<span :class="['badge', run.status === 'recording' ? 'badge-warning' : 'badge-muted']">
{{ run.status }}
</span>
<span class="run-label">{{ run.label || 'Untitled Run' }}</span>
<span class="text-muted">·</span>
<span class="text-secondary mono" style="font-size: 0.8rem">{{ run.feed_name }}</span>
<span class="text-muted">·</span>
<span class="text-secondary" style="font-size: 0.8rem">{{ run.frame_count }} frames</span>
<span v-if="run.data_truncated" class="badge badge-warning" style="font-size: 0.65rem">TRUNCATED</span>
</div>
<div class="flex gap-1">
<button
v-if="run.status === 'recording'"
class="btn btn-secondary btn-sm"
@click="handleStopRun(run.id)"
>
■ Stop
</button>
<button
class="btn btn-danger btn-sm"
@click="handleDeleteRun(run.id, run.label || '')"
>
✕
</button>
</div>
</div>
</div>
</section>
<!-- Complete / Analysis -->
<section class="card">
<div class="card-header">
<h2>Analysis</h2>
</div>
<div v-if="!isCompleted" class="text-secondary">
<p class="mb-2">Complete the session to trigger automatic analysis.</p>
<button
class="btn btn-primary"
:disabled="session.runs.length === 0 || hasActiveRun"
@click="handleComplete"
>
✓ Complete Session
</button>
<p v-if="hasActiveRun" class="text-warning mt-1" style="font-size: 0.85rem">
Stop all active runs before completing.
</p>
<p v-if="session.runs.length === 0" class="text-muted mt-1" style="font-size: 0.85rem">
Add at least one run before completing.
</p>
</div>
<div v-else-if="analysisData">
<div class="analysis-summary mb-2">
<span class="badge badge-success">Analyzed</span>
<span class="text-secondary" style="font-size: 0.85rem; margin-left: 0.5rem">
{{ session.use_case.replace(/_/g, ' ') }}
</span>
</div>
<!-- Quick stats for shift points -->
<div v-if="shiftAnalysis" class="analysis-stats grid grid-3 mb-2">
<div class="stat-card">
<span class="stat-label">Recommended Shift RPM</span>
<span class="stat-value mono">{{ Math.round(shiftAnalysis.recommended_shift_rpm ?? 0) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Crossover Points</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>
<!-- Quick stats for track map -->
<div v-if="trackAnalysis" class="analysis-stats grid grid-2 mb-2">
<div class="stat-card">
<span class="stat-label">Boundaries</span>
<span class="stat-value mono">{{ trackAnalysis.boundaries.length }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Total Points</span>
<span class="stat-value mono">{{ trackAnalysis.total_points.toLocaleString() }}</span>
</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">
<button v-if="session.use_case === 'shift_points' || session.use_case === 'car_grip'" class="btn btn-primary" @click="viewAnalysis">
📊 View Full Analysis
</button>
<button class="btn btn-secondary" @click="handleReAnalyze">
🔄 Re-analyze
</button>
<button class="btn btn-secondary" @click="handleReopen">
↩ Reopen Session
</button>
</div>
</div>
<div v-else class="text-secondary">
<p>Analysis not available.</p>
<button class="btn btn-secondary btn-sm mt-1" @click="handleReAnalyze">
Run Analysis
</button>
</div>
</section>
<!-- Track Mapping: Build Track Model -->
<section v-if="isTrackMapping && isCompleted" class="card mb-3">
<div class="card-header">
<h2>Build Track Model</h2>
</div>
<div v-if="canBuildTrack">
<p class="text-secondary mb-2" style="font-size: 0.85rem">
Both boundary runs (left + right) are recorded. Build the persistent track model to enable corner analysis and sector timing.
</p>
<button
class="btn btn-primary"
:disabled="buildingTrack"
@click="handleBuildTrack"
>
{{ buildingTrack ? 'Building...' : 'Build Track Model' }}
</button>
<div v-if="buildTrackSuccess" class="text-success mt-2" style="font-size: 0.85rem">
Track model built successfully. <RouterLink to="/tracks">View in Tracks →</RouterLink>
</div>
<div v-if="buildTrackError" class="text-error mt-2" style="font-size: 0.85rem">
{{ buildTrackError }}
</div>
</div>
<div v-else class="text-secondary" style="font-size: 0.85rem">
<p>Requires 2 completed runs labeled "left" and "right".</p>
<div class="flex gap-2 mt-1">
<span :class="hasLeftRun ? 'text-success' : 'text-muted'">
{{ hasLeftRun ? '✓' : '○' }} Left boundary
</span>
<span :class="hasRightRun ? 'text-success' : 'text-muted'">
{{ hasRightRun ? '✓' : '○' }} Right boundary
</span>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.page-title {
font-size: 1.5rem;
margin: 0;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.info-label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.run-form {
padding: 0.75rem;
background: var(--color-background);
border-radius: var(--radius-sm);
}
.run-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.run-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.75rem;
background: var(--color-surface-hover);
border-radius: var(--radius-sm);
}
.run-label {
font-weight: 500;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: var(--color-background);
border-radius: var(--radius-sm);
}
.stat-card .stat-label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-card .stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.form-select,
.form-input {
padding: 0.4rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.85rem;
}
.form-select:focus,
.form-input:focus {
outline: none;
border-color: var(--color-primary);
}
.form-input {
flex: 1;
min-width: 150px;
}
.merge-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
cursor: pointer;
}
.merge-feed-option {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
padding: 0.2rem 0.5rem;
background: var(--color-background);
border-radius: var(--radius-sm);
cursor: pointer;
}
</style>