Skip to content
17 changes: 17 additions & 0 deletions angular-client/src/api/lap-timer.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { urls } from './urls';

export const startLap = (): Promise<Response> => {
return fetch(urls.startLap(), { method: 'POST' });
};

export const pauseLap = (): Promise<Response> => {
return fetch(urls.pauseLap(), { method: 'POST' });
};

export const stopLap = (): Promise<Response> => {
return fetch(urls.stopLap(), { method: 'POST' });
};

export const getLaps = (): Promise<Response> => {
return fetch(urls.getLaps());
};
11 changes: 11 additions & 0 deletions angular-client/src/api/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

/* Rules */
const getRulesByClientId = (clientId: string) => `${baseURL}/rules/${clientId}`;
const addRule = () => `${baseURL}/rules/add`;
Expand Down Expand Up @@ -76,6 +82,11 @@ export const urls = {

carCommandConfig,

startLap,
pauseLap,
stopLap,
getLaps,

getRulesByClientId,
addRule,
deleteRule,
Expand Down
6 changes: 6 additions & 0 deletions angular-client/src/app/app-nav-bar/app-nav-bar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ export class AppNavBarComponent implements OnInit, OnDestroy {
label: 'Rules',
onClick: () => this.navigateTo(appRoutes.rulesRoute()),
icon: 'notifications'
},
{
id: appRoutes.lapTimerRoute(),
label: 'Lap Timer',
onClick: () => this.navigateTo(appRoutes.lapTimerRoute()),
icon: 'timer'
}
];

Expand Down
8 changes: 6 additions & 2 deletions angular-client/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import NotificationLogPageComponent from 'src/pages/notification-log-page/notifi
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 NotificationRulesPageComponent from 'src/pages/notification-rules-page/notification-rules-page.component';
import { Segment } from 'src/utils/bms.utils';
Expand All @@ -27,6 +28,7 @@ const commandsRoute = () => `/commands`;
const rulesRoute = () => `/rules`;
const efusesRoute = () => `/efuses`;
const notificationLogRoute = () => `/notification-log`;
const lapTimerRoute = () => `/lap-timer`;

export const appRoutes = {
landingRoute,
Expand All @@ -41,7 +43,8 @@ export const appRoutes = {
commandsRoute,
rulesRoute,
efusesRoute,
notificationLogRoute
notificationLogRoute,
lapTimerRoute
};

// Routes should be defined carefully in accordance with the appRoutes
Expand All @@ -60,7 +63,8 @@ const routes: Routes = [
{ path: 'commands', component: CarCommandComponent },
{ path: 'rules', component: NotificationRulesPageComponent },
{ path: 'efuses', component: EfusesPageComponent },
{ path: 'notification-log', component: NotificationLogPageComponent }
{ path: 'notification-log', component: NotificationLogPageComponent },
{ path: 'lap-timer', component: LapTimerPageComponent }
];

@NgModule({
Expand Down
48 changes: 39 additions & 9 deletions angular-client/src/components/half-gauge/half-gauge.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';

import { ApexNonAxisChartSeries, ApexPlotOptions, ApexChart, ApexFill, NgApexchartsModule } from 'ng-apexcharts';
import { NgStyle } from '@angular/common';
Expand All @@ -18,7 +18,7 @@ export type ChartOptions = {
standalone: true,
imports: [NgStyle, NgApexchartsModule]
})
export default class HalfGaugeComponent implements OnInit {
export default class HalfGaugeComponent implements OnInit, OnChanges {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public chartOptions!: Partial<ChartOptions> | any;
@Input() current: number = 50;
Expand All @@ -30,26 +30,52 @@ export default class HalfGaugeComponent implements OnInit {

widthpx: string = '200px';
heightpx: string = '200px';
paddingTop: string = '20px';
label: string = 'm/s';
percentage: number = 50;
fontsize: string = '50px';

ngOnInit() {
this.rebuildChart();
}

ngOnChanges(changes: SimpleChanges) {
if (!this.chartOptions) return; // ngOnInit handles the first build
if (changes['size']) {
this.widthpx = this.size + 'px';
this.heightpx = this.size * 0.5 + 'px';
this.fontsize = this.size / 10 + 'px';
}
if (changes['current'] || changes['min'] || changes['max']) {
this.percentage = ((this.current - this.min) / (this.max - this.min)) * 100;
this.label = formatGaugeValue(this.current) + this.unit;
// New object so ng-apexcharts diffs and re-renders.
this.chartOptions = {
...this.chartOptions,
series: [this.percentage],
labels: [this.label]
};
} else if (changes['unit']) {
this.label = formatGaugeValue(this.current) + this.unit;
this.chartOptions = { ...this.chartOptions, labels: [this.label] };
}
if (changes['color']) {
this.chartOptions = { ...this.chartOptions, fill: { ...this.chartOptions.fill, colors: [this.color] } };
}
}

private rebuildChart(): void {
this.widthpx = this.size + 'px';
this.heightpx = this.size * 0.5 + 'px';
this.paddingTop = '';
this.label = this.current + this.unit;
this.label = formatGaugeValue(this.current) + this.unit;
this.percentage = ((this.current - this.min) / (this.max - this.min)) * 100;
this.fontsize = this.size / 10 + 'px';

// apex radial charts are hard coded to work with percentages, so converting to percentage to
// accurately represent min and max in chart and then using actual value and unit as label
// radialBar takes percentages; raw value goes in the label.
this.chartOptions = {
series: [this.percentage],
chart: {
type: 'radialBar',
foreColor: '#eeeeee', // text color
foreColor: '#eeeeee',
redrawOnParentResize: true,
offsetY: -100
},
Expand All @@ -65,7 +91,7 @@ export default class HalfGaugeComponent implements OnInit {
track: {
background: '#1d1d1d',
strokeWidth: '97%',
margin: 5, // margin is in pixels
margin: 5,
dropShadow: {
enabled: false,
top: 2,
Expand Down Expand Up @@ -97,3 +123,7 @@ export default class HalfGaugeComponent implements OnInit {
};
}
}

function formatGaugeValue(n: number): string {
return (Math.round(n * 100) / 100).toFixed(2);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.page-grid {
margin: 0 16px;
}

.gauge-wrap {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
overflow: hidden;
}

.gauge-stat {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
padding: 8px 0;
}
.gauge-value {
font-family: var(--font-family);
font-variant-numeric: tabular-nums;
font-size: var(--font-size-xxl);
font-weight: 700;
color: var(--color-text-primary);
}
.gauge-unit {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<div class="page-grid">
<p-toast position="top-right" />
<p-confirmDialog />

<mat-grid-list cols="6" gutterSize="15px" rowHeight="1.5rem">
<mat-grid-tile [colspan]="6" [rowspan]="8">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abstraction ticket needed

<sessions-panel />
</mat-grid-tile>

<mat-grid-tile [colspan]="3" [rowspan]="9">
<timer-hero />
</mat-grid-tile>

<mat-grid-tile [colspan]="1" [rowspan]="9">
<session-summary />
</mat-grid-tile>

<mat-grid-tile [colspan]="2" [rowspan]="5">
<info-background svgIcon="speed" title="Speed" style="width: 100%; height: 100%">
<div class="gauge-wrap">
<half-gauge [current]="liveSpeed()" [max]="100" [min]="0" unit="mph" [color]="speedGaugeColor()" [size]="320" />
</div>
</info-background>
</mat-grid-tile>

<mat-grid-tile [colspan]="1" [rowspan]="4">
<info-background title="Battery" style="width: 100%; height: 100%">
<div class="gauge-stat">
<span class="gauge-value" [style.color]="socColor()">{{ liveSoc() | number: '1.0-0' }}</span>
<span class="gauge-unit">%</span>
</div>
</info-background>
</mat-grid-tile>

<mat-grid-tile [colspan]="1" [rowspan]="4">
<info-background title="Motor" style="width: 100%; height: 100%">
<div class="gauge-stat">
<span class="gauge-value" [style.color]="motorTempColor()">{{ liveMotorTemp() | number: '1.0-0' }}</span>
<span class="gauge-unit">°C</span>
</div>
</info-background>
</mat-grid-tile>

<mat-grid-tile [colspan]="6" [rowspan]="22">
<laps-table />
</mat-grid-tile>
</mat-grid-list>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ChangeDetectionStrategy, Component, computed, 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 { ConfirmationService, MessageService } from 'primeng/api';
import { ConfirmDialog } from 'primeng/confirmdialog';
import { Toast } from 'primeng/toast';
import HalfGaugeComponent from 'src/components/half-gauge/half-gauge.component';
import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component';
import Storage from 'src/services/storage.service';
import { topics } from 'src/utils/topic.utils';
import SessionsPanelComponent from './sessions-panel/sessions-panel.component';
import TimerHeroComponent from './timer-hero/timer-hero.component';
import SessionSummaryComponent from './session-summary/session-summary.component';
import LapsTableComponent from './laps-table/laps-table.component';

@Component({
selector: 'lap-timer-page',
templateUrl: './lap-timer-page.component.html',
styleUrl: './lap-timer-page.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ConfirmationService, MessageService],
imports: [
MatGridList,
MatGridTile,
DecimalPipe,
ConfirmDialog,
Toast,
HalfGaugeComponent,
InfoBackgroundComponent,
SessionsPanelComponent,
TimerHeroComponent,
SessionSummaryComponent,
LapsTableComponent
]
})
export default class LapTimerPageComponent implements OnInit, OnDestroy {
private storage = inject(Storage);

readonly liveSpeed = signal(0);
readonly liveMotorTemp = signal(0);
readonly liveSoc = signal(0);

readonly speedGaugeColor = signal('#1ae824');

readonly socColor = computed(() => {
const soc = this.liveSoc();
if (soc >= 60) return 'var(--color-battery-high)';
if (soc >= 30) return 'var(--color-battery-med)';
return 'var(--color-battery-low)';
});

readonly motorTempColor = computed(() => {
const t = this.liveMotorTemp();
if (t < 60) return 'var(--color-text-primary)';
if (t < 80) return 'var(--color-battery-med)';
return 'var(--color-battery-low)';
});

private subs: Subscription[] = [];

ngOnInit(): void {
const styles = getComputedStyle(document.documentElement);
const high = styles.getPropertyValue('--color-battery-high').trim();
if (high) this.speedGaugeColor.set(high);

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());
}
}
Loading