diff --git a/angular-client/src/api/lap-timer.api.ts b/angular-client/src/api/lap-timer.api.ts new file mode 100644 index 00000000..0b3c93bc --- /dev/null +++ b/angular-client/src/api/lap-timer.api.ts @@ -0,0 +1,17 @@ +import { urls } from './urls'; + +export const startLap = (): Promise => { + return fetch(urls.startLap(), { method: 'POST' }); +}; + +export const pauseLap = (): Promise => { + return fetch(urls.pauseLap(), { method: 'POST' }); +}; + +export const stopLap = (): Promise => { + return fetch(urls.stopLap(), { method: 'POST' }); +}; + +export const getLaps = (): Promise => { + return fetch(urls.getLaps()); +}; diff --git a/angular-client/src/api/urls.ts b/angular-client/src/api/urls.ts index 4c827fe9..41284340 100644 --- a/angular-client/src/api/urls.ts +++ b/angular-client/src/api/urls.ts @@ -35,6 +35,12 @@ const updateVideos = () => `${getAllVideos()}/update`; const carCommandConfig = (key: string, values: number[]) => `${baseURL}/config/set/${key}?${values.map((value) => `data=${value}`).join('&')}`; +/* Lap Timer */ +const startLap = () => `${baseURL}/lap-timer/start`; +const pauseLap = () => `${baseURL}/lap-timer/pause`; +const stopLap = () => `${baseURL}/lap-timer/stop`; +const getLaps = () => `${baseURL}/lap-timer/laps`; + /* Authentication */ const authenticate = () => `${baseURL}/authenticate`; @@ -67,6 +73,12 @@ export const urls = { updateVideos, carCommandConfig, + + startLap, + pauseLap, + stopLap, + getLaps, + authenticate, scyllaSettings, diff --git a/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts b/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts index 3f6edae5..f5a02a57 100644 --- a/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts +++ b/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts @@ -189,6 +189,12 @@ export class AppNavBarComponent implements OnInit, OnDestroy { label: 'Commands', onClick: () => this.navigateTo(appRoutes.commandsRoute()), icon: 'electrical_services' + }, + { + id: appRoutes.lapTimerRoute(), + label: 'Lap Timer', + onClick: () => this.navigateTo(appRoutes.lapTimerRoute()), + icon: 'timer' } ]; diff --git a/angular-client/src/app/app-routing.module.ts b/angular-client/src/app/app-routing.module.ts index 6ef5e9f0..6ba938ad 100644 --- a/angular-client/src/app/app-routing.module.ts +++ b/angular-client/src/app/app-routing.module.ts @@ -9,6 +9,7 @@ import EfusesPageComponent from 'src/pages/efuses-page/efuses-page.component'; import FaultPageComponent from 'src/pages/fault-page/fault-page.component'; import GraphPageComponent from 'src/pages/graph-page/graph-page.component'; import LandingPageComponent from 'src/pages/landing-page/landing-page.component'; +import LapTimerPageComponent from 'src/pages/lap-timer-page/lap-timer-page.component'; import MapComponent from 'src/pages/map/map.component'; import { Segment } from 'src/utils/bms.utils'; @@ -23,6 +24,7 @@ const faultsRoute = () => `/faults`; const faultsGraphRoute = () => `/faults/fault-graph`; const commandsRoute = () => `/commands`; const efusesRoute = () => `/efuses`; +const lapTimerRoute = () => `/lap-timer`; export const appRoutes = { landingRoute, @@ -35,7 +37,8 @@ export const appRoutes = { faultsRoute, faultsGraphRoute, commandsRoute, - efusesRoute + efusesRoute, + lapTimerRoute }; // Routes should be defined carefully in accordance with the appRoutes @@ -52,7 +55,8 @@ const routes: Routes = [ { path: 'faults/fault-graph', component: GraphPageComponent }, { path: 'camera', component: CameraPageComponent }, { path: 'commands', component: CarCommandComponent }, - { path: 'efuses', component: EfusesPageComponent } + { path: 'efuses', component: EfusesPageComponent }, + { path: 'lap-timer', component: LapTimerPageComponent } ]; @NgModule({ diff --git a/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css new file mode 100644 index 00000000..0c288f99 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css @@ -0,0 +1,161 @@ +.page-grid { + margin: 0 16px; +} +.timer-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 8px 0; +} +.timer-display, +.current-lap-row { + display: flex; + flex-direction: column; + align-items: center; +} +.timer-display { + gap: 4px; +} +.current-lap-row { + gap: 2px; +} +.time-value, +.current-lap-time, +.avg-lap-time, +.stat-value, +.lap-delta { + font-family: 'Roboto', monospace; + font-variant-numeric: tabular-nums; +} +.time-value { + font-size: 2.8rem; + font-weight: bold; + color: #fbf7f5; +} +.current-lap-time { + font-size: 1.1rem; + font-weight: bold; + color: #cacaca; +} +.controls { + display: flex; + justify-content: center; +} +.control-btn { + border: none; + border-radius: 50px; + padding: 10px 28px; + font-size: 15px; + font-weight: bold; + cursor: pointer; + min-width: 90px; +} +.start-btn, +.pause-btn, +.stop-btn { + color: #fff; +} +.start-btn { + background: #1db954; +} +.pause-btn { + background: #f0a030; +} +.stop-btn { + background: #f04346; +} +.lap-btn, +.reset-btn { + background: #3a3a3a; + color: #efefef; + border: 1px solid #555; +} +.session-stats, +.lap-list-full { + display: flex; + flex-direction: column; +} +.session-stats { + gap: 8px; + padding: 4px 8px; +} +.live-big { + font-family: 'Roboto', monospace; + font-size: 2rem; + font-weight: bold; + color: #fbf7f5; + font-variant-numeric: tabular-nums; +} +.live-unit { + font-size: 0.85rem; + color: #999; + margin-left: 4px; +} +.lap-list-full { + width: 100%; +} +.lap-list-header { + padding: 6px 8px; + border-bottom: 1px solid #444; +} +.lap-list { + flex: 1; + overflow-y: auto; +} +.lap-entry { + border-bottom: 1px solid #333; +} +.lap-row { + padding: 8px; +} +.best-lap { + border-left: 3px solid #1db954; + padding-left: 8px; +} +.worst-lap { + border-left: 3px solid #f04346; + padding-left: 8px; +} +.stat-row { + display: flex; + justify-content: space-between; +} +.stat-label, +.stat-value { + font-size: 13px; +} +.stat-label { + color: #888; +} +.stat-value { + color: #ccc; +} +.best-text { + color: #1db954; +} +.lap-time-col { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; +} +.lap-delta { + font-size: 11px; +} +.delta-positive { + color: #f04346; +} +.delta-negative { + color: #1db954; +} +.avg-lap { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; +} +.avg-lap-time { + font-size: 1rem; + color: #999; +} diff --git a/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html new file mode 100644 index 00000000..7dddc333 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html @@ -0,0 +1,183 @@ +
+ + + + +
+
+ +
{{ timer.formattedTotal() }}
+
+ +
{{ timer.formattedCurrentLap() }}
+
+
+
+ @if (timer.isIdle()) { + + } @else if (timer.isRunning()) { + + + + + } @else if (timer.isPaused()) { + + + + + } +
+ @if (timer.isIdle() && timer.laps().length > 0) { + + } +
+
+
+ + + + +
+
+ {{ timer.lapCount() }} +
+
Laps
+
+ Best{{ + timer.bestLap() ? timer.formatTime(timer.bestLap()!.duration) : '—' + }} +
+
+ Avg{{ timer.lapCount() > 0 ? timer.formatTime(timer.averageLapTime()) : '—' }} +
+
+ Energy{{ timer.totalEnergyUsed() | number: '1.1-1' }}% +
+
+
+
+ + + + + + + + + + + +
+ {{ liveSoc() | number: '1.0-0' }} + % +
+
+
+ + + +
+ {{ liveMotorTemp() | number: '1.0-0' }}° + C +
+
+
+ + + + +
+ @if (timer.laps().length > 0 || timer.isRunning() || timer.isPaused()) { +
+ + + + + + + + + +
+
+ @for (lap of timer.laps().slice().reverse(); track lap.number) { +
+
+ + + +
+ + @if (timer.deltaFromBest(lap.duration); as delta) { + @if (delta !== 0) { + {{ timer.formatDelta(delta) }} + } + } +
+ {{ + lap.stats.avgSpeed !== null ? (lap.stats.avgSpeed | number: '1.0-0') + ' mph' : '—' + }} + {{ + lap.stats.energyUsed !== null ? (lap.stats.energyUsed | number: '1.2-2') + '%' : '—' + }} + {{ + lap.stats.maxMotorTemp !== null ? (lap.stats.maxMotorTemp | number: '1.0-0') + '°C' : '—' + }} +
+
+
+
+ } +
+ @if (timer.laps().length > 1) { +
+ + {{ timer.formatTime(timer.averageLapTime()) }} +
+ } + } @else { +
+ +
+ } +
+
+
+
+
diff --git a/angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts new file mode 100644 index 00000000..cc87ad83 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts @@ -0,0 +1,76 @@ +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { MatGridList, MatGridTile } from '@angular/material/grid-list'; +import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component'; +import TypographyComponent from 'src/components/typography/typography.component'; +import HStackComponent from 'src/components/hstack/hstack.component'; +import HalfGaugeComponent from 'src/components/half-gauge/half-gauge.component'; +import LapTimerService from 'src/services/lap-timer.service'; +import Storage from 'src/services/storage.service'; +import { topics } from 'src/utils/topic.utils'; + +@Component({ + selector: 'lap-timer-page', + templateUrl: './lap-timer-page.component.html', + styleUrl: './lap-timer-page.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatGridList, + MatGridTile, + InfoBackgroundComponent, + TypographyComponent, + HStackComponent, + HalfGaugeComponent, + DecimalPipe + ] +}) +export default class LapTimerPageComponent implements OnInit, OnDestroy { + readonly timer = inject(LapTimerService); + private storage = inject(Storage); + + readonly liveSpeed = signal(0); + readonly liveMotorTemp = signal(0); + readonly liveSoc = signal(0); + + private subs: Subscription[] = []; + + ngOnInit(): void { + this.subs.push( + this.storage.get(topics.speed()).subscribe((v) => { + this.liveSpeed.set(parseFloat(v.values[0]) || 0); + }), + this.storage.get(topics.motorTemp()).subscribe((v) => { + this.liveMotorTemp.set(parseFloat(v.values[0]) || 0); + }), + this.storage.get(topics.stateOfCharge()).subscribe((v) => { + this.liveSoc.set(parseFloat(v.values[0]) || 0); + }) + ); + } + + ngOnDestroy(): void { + this.subs.forEach((s) => s.unsubscribe()); + } + + onStartResume(): void { + if (this.timer.isIdle()) { + this.timer.start(); + } else if (this.timer.isPaused()) { + this.timer.resume(); + } + } + + onPause(): void { + this.timer.pause(); + } + onLap(): void { + this.timer.lap(); + } + onStop(): void { + this.timer.stop(); + } + onReset(): void { + this.timer.reset(); + } +} diff --git a/angular-client/src/services/lap-timer.service.ts b/angular-client/src/services/lap-timer.service.ts new file mode 100644 index 00000000..cade61e6 --- /dev/null +++ b/angular-client/src/services/lap-timer.service.ts @@ -0,0 +1,237 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { startLap as apiStartLap, pauseLap as apiPauseLap, stopLap as apiStopLap } from 'src/api/lap-timer.api'; +import { topics } from 'src/utils/topic.utils'; +import Storage from './storage.service'; + +export type LapState = 'idle' | 'running' | 'paused'; + +export interface LapStats { + avgSpeed: number | null; + maxSpeed: number | null; + socStart: number | null; + socEnd: number | null; + energyUsed: number | null; // SOC delta as percentage points + maxMotorTemp: number | null; +} + +export interface LapRecord { + number: number; + duration: number; + stats: LapStats; +} + +@Injectable({ providedIn: 'root' }) +export default class LapTimerService { + private storage = inject(Storage); + + readonly state = signal('idle'); + readonly currentLapTime = signal(0); + readonly totalTime = signal(0); + readonly laps = signal([]); + + private rafId: number | null = null; + private lastTickTime = 0; + + // Telemetry accumulators for current lap + private speedSamples: number[] = []; + private lastSoc: number | null = null; + private lapSocStart: number | null = null; + private lapMaxMotorTemp: number | null = null; + private telemetrySubs: Subscription[] = []; + + readonly isRunning = computed(() => this.state() === 'running'); + readonly isPaused = computed(() => this.state() === 'paused'); + readonly isIdle = computed(() => this.state() === 'idle'); + readonly lapCount = computed(() => this.laps().length); + + readonly formattedCurrentLap = computed(() => this.formatTime(this.currentLapTime())); + readonly formattedTotal = computed(() => this.formatTime(this.totalTime())); + + readonly bestLap = computed(() => { + const laps = this.laps(); + if (laps.length === 0) return null; + return laps.reduce((best, lap) => (lap.duration < best.duration ? lap : best)); + }); + + readonly worstLap = computed(() => { + const laps = this.laps(); + if (laps.length < 2) return null; + return laps.reduce((worst, lap) => (lap.duration > worst.duration ? lap : worst)); + }); + + readonly averageLapTime = computed(() => { + const laps = this.laps(); + if (laps.length === 0) return 0; + return laps.reduce((sum, lap) => sum + lap.duration, 0) / laps.length; + }); + + // Session-level computed stats + readonly totalEnergyUsed = computed(() => { + const laps = this.laps(); + return laps.reduce((sum, lap) => sum + (lap.stats.energyUsed ?? 0), 0); + }); + + deltaFromBest(lapDuration: number): number | null { + const best = this.bestLap(); + if (!best) return null; + return lapDuration - best.duration; + } + + formatDelta(deltaMs: number): string { + const sign = deltaMs >= 0 ? '+' : '-'; + return `${sign}${this.formatTime(Math.abs(deltaMs))}`; + } + + start(): void { + if (this.state() === 'idle') { + this.laps.set([]); + this.currentLapTime.set(0); + this.totalTime.set(0); + } + this.state.set('running'); + this.startTicking(); + this.subscribeTelemetry(); + apiStartLap().catch(() => {}); + } + + pause(): void { + if (this.state() !== 'running') return; + this.state.set('paused'); + this.stopTicking(); + apiPauseLap().catch(() => {}); + } + + resume(): void { + if (this.state() !== 'paused') return; + this.state.set('running'); + this.startTicking(); + apiStartLap().catch(() => {}); + } + + lap(): void { + if (this.state() !== 'running') return; + const lapDuration = this.currentLapTime(); + if (lapDuration === 0) return; + const stats = this.snapshotStats(); + this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: lapDuration, stats }]); + this.currentLapTime.set(0); + this.resetLapAccumulators(); + } + + stop(): void { + const remaining = this.currentLapTime(); + if (remaining > 0) { + const stats = this.snapshotStats(); + this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: remaining, stats }]); + } + this.stopTicking(); + this.unsubscribeTelemetry(); + this.state.set('idle'); + this.currentLapTime.set(0); + apiStopLap().catch(() => {}); + } + + reset(): void { + this.stopTicking(); + this.unsubscribeTelemetry(); + this.state.set('idle'); + this.currentLapTime.set(0); + this.totalTime.set(0); + this.laps.set([]); + } + + formatTime(timeMs: number): string { + const totalSeconds = Math.floor(timeMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const centiseconds = Math.floor((timeMs % 1000) / 10); + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + } + + // --- Telemetry tracking --- + + private subscribeTelemetry(): void { + this.resetLapAccumulators(); + + this.telemetrySubs.push( + this.storage.get(topics.speed()).subscribe((value) => { + if (this.state() !== 'running') return; + const speed = parseFloat(value.values[0]); + if (!isNaN(speed)) { + this.speedSamples.push(speed); + } + }) + ); + + this.telemetrySubs.push( + this.storage.get(topics.stateOfCharge()).subscribe((value) => { + if (this.state() !== 'running') return; + const soc = parseFloat(value.values[0]); + if (!isNaN(soc)) { + if (this.lapSocStart === null) this.lapSocStart = soc; + this.lastSoc = soc; + } + }) + ); + + this.telemetrySubs.push( + this.storage.get(topics.motorTemp()).subscribe((value) => { + if (this.state() !== 'running') return; + const temp = parseFloat(value.values[0]); + if (!isNaN(temp)) { + this.lapMaxMotorTemp = this.lapMaxMotorTemp === null ? temp : Math.max(this.lapMaxMotorTemp, temp); + } + }) + ); + } + + private unsubscribeTelemetry(): void { + this.telemetrySubs.forEach((sub) => sub.unsubscribe()); + this.telemetrySubs = []; + } + + private snapshotStats(): LapStats { + const avgSpeed = + this.speedSamples.length > 0 ? this.speedSamples.reduce((a, b) => a + b, 0) / this.speedSamples.length : null; + const maxSpeed = this.speedSamples.length > 0 ? Math.max(...this.speedSamples) : null; + const energyUsed = this.lapSocStart !== null && this.lastSoc !== null ? this.lapSocStart - this.lastSoc : null; + + return { + avgSpeed, + maxSpeed, + socStart: this.lapSocStart, + socEnd: this.lastSoc, + energyUsed, + maxMotorTemp: this.lapMaxMotorTemp + }; + } + + private resetLapAccumulators(): void { + this.speedSamples = []; + this.lapSocStart = this.lastSoc; // carry over current SOC as next lap's start + this.lapMaxMotorTemp = null; + } + + // --- Timer internals --- + + private startTicking(): void { + this.lastTickTime = performance.now(); + const tick = () => { + const now = performance.now(); + const delta = now - this.lastTickTime; + this.lastTickTime = now; + this.currentLapTime.update((t) => t + delta); + this.totalTime.update((t) => t + delta); + this.rafId = requestAnimationFrame(tick); + }; + this.rafId = requestAnimationFrame(tick); + } + + private stopTicking(): void { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + } +}