import type { Ride } from '../components/app-cloud-data';
import { parseGpxToTelemetryPoints, parseCsvToTelemetryPoints, normalizeTelemetryPoints, areTelemetryPointsConnected } from './telemetry-track';
export { parseGpxToTelemetryPoints, parseCsvToTelemetryPoints } from './telemetry-track';
export interface TelemetryPoint {
timestamp: string; // ISO string
latitude: number;
longitude: number;
altitude: number; // in feet or meters
speed: number; // mph
heading: number; // degrees 0-360
gpsAccuracy: number; // meters
leanAngle: number; // degrees (- for left, + for right or signed)
pitchAngle: number;
accelX: number;
accelY: number;
accelZ: number;
longitudinalG: number;
lateralG: number;
segmentId?: number; // Disconnected recording segment; never connect across a break.
}
export interface CornerApexSummary {
id: number;
direction: 'Left' | 'Right';
entrySpeed: number;
apexSpeed: number;
exitSpeed: number;
apexLeanAngle: number;
grade: 'A+' | 'A' | 'B' | 'C' | 'D';
startIndex: number;
apexIndex: number;
endIndex: number;
}
/**
* Triggers an immediate browser file download for a text blob (GPX or CSV).
*/
export function downloadFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
import { firebaseAuth } from './firebase-client';
/**
* Authoritative GPX 1.1 Exporter matching MotorSignal's ExportService schema.
* Rejects export if no recorded telemetry points are present.
*/
export function generateRideGpx(ride: Ride, points?: TelemetryPoint[]): string {
const telemetry = points && points.length > 0 ? points : synthesizeTelemetryTrack(ride);
if (!telemetry || telemetry.length === 0) {
throw new Error(`Cannot export ride ${ride.id}: no recorded telemetry points available.`);
}
const rideDate = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session';
const rideTitle = `${ride.motorcycle || 'Motorcycle'} Ride - ${rideDate}`;
const lines: string[] = [
'',
'',
' ',
` ${escapeXml(rideTitle)}`,
` `,
' ',
' ',
` ${escapeXml(rideTitle)}`,
' ',
];
for (const [index, pt] of telemetry.entries()) {
if (index > 0 && !areTelemetryPointsConnected(telemetry[index - 1], pt)) {
lines.push(' ', ' ');
}
lines.push(` `);
if (pt.altitude != null) {
lines.push(` ${pt.altitude.toFixed(1)}`);
}
lines.push(` `);
lines.push(' ');
lines.push(` ${pt.speed.toFixed(1)}`);
lines.push(` ${pt.heading.toFixed(1)}`);
lines.push(` ${pt.leanAngle.toFixed(1)}`);
lines.push(` ${pt.longitudinalG.toFixed(2)}`);
lines.push(` ${pt.lateralG.toFixed(2)}`);
lines.push(' ');
lines.push(' ');
}
lines.push(' ');
lines.push(' ');
lines.push('');
return lines.join('\n');
}
/**
* Authoritative 20 Hz Telemetry CSV Exporter matching MotorSignal's ExportService RFC-4180 format.
* Rejects export if no recorded telemetry points are present.
*/
export function generateRideCsv(ride: Ride, points?: TelemetryPoint[]): string {
const telemetry = points && points.length > 0 ? points : synthesizeTelemetryTrack(ride);
if (!telemetry || telemetry.length === 0) {
throw new Error(`Cannot export ride ${ride.id}: no recorded telemetry points available.`);
}
const header = 'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG';
const rows = telemetry.map((pt) =>
[
pt.timestamp,
pt.latitude.toFixed(6),
pt.longitude.toFixed(6),
pt.altitude.toFixed(1),
pt.speed.toFixed(2),
pt.heading.toFixed(1),
pt.gpsAccuracy.toFixed(1),
pt.leanAngle.toFixed(2),
pt.pitchAngle.toFixed(2),
pt.accelX.toFixed(3),
pt.accelY.toFixed(3),
pt.accelZ.toFixed(3),
pt.longitudinalG.toFixed(3),
pt.lateralG.toFixed(3),
].join(',')
);
return [header, ...rows].join('\n');
}
/**
* Resolves real telemetry points for a ride:
* 1. Uses inline points/gpx/csv if present.
* 2. Fetches cloud telemetry bundle from /api/account/rides/:id/telemetry if not inline.
* 3. Throws if no recorded telemetry is available.
*/
export async function resolveRideTelemetryPoints(ride: Ride): Promise {
const inline = synthesizeTelemetryTrack(ride);
if (inline && inline.length > 0) {
return inline;
}
if (typeof window !== 'undefined' && firebaseAuth) {
const user = firebaseAuth.currentUser;
if (!user) {
throw new Error('Please sign in to load recorded cloud telemetry.');
}
const token = await user.getIdToken();
const response = await fetch(`/api/account/rides/${encodeURIComponent(ride.id)}/telemetry`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Recorded cloud telemetry could not be loaded.');
}
const points = normalizeTelemetryPoints(Array.isArray(data.points) ? data.points : []);
if (!points.length) {
throw new Error('No recorded telemetry points found in cloud bundle.');
}
return points;
}
throw new Error('No recorded telemetry points available for this ride.');
}
/**
* Escape XML special characters.
*/
function escapeXml(unsafe: string): string {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '<';
case '>': return '>';
case '&': return '&';
case '\'': return ''';
case '"': return '"';
default: return c;
}
});
}
/**
* Detects corners along a telemetry track, calculates apex metrics and assigns an apex grade.
*/
export function analyzeCorners(points: TelemetryPoint[]): CornerApexSummary[] {
const corners: CornerApexSummary[] = [];
if (points.length < 10) return corners;
let inCorner = false;
let startIndex = 0;
let currentMaxLean = 0;
let apexIdx = 0;
let cornerDir: 'Left' | 'Right' = 'Left';
for (let i = 0; i < points.length; i++) {
const lean = points[i].leanAngle;
const absLean = Math.abs(lean);
if (absLean > 14.0) {
if (!inCorner) {
inCorner = true;
startIndex = Math.max(0, i - 1);
currentMaxLean = absLean;
apexIdx = i;
cornerDir = lean < 0 ? 'Left' : 'Right';
} else {
if (absLean > currentMaxLean) {
currentMaxLean = absLean;
apexIdx = i;
cornerDir = lean < 0 ? 'Left' : 'Right';
}
}
} else {
if (inCorner) {
// Corner ended
const endIndex = i;
const durationPoints = endIndex - startIndex;
// Require at least ~0.5s of continuous lean (10 samples at 20Hz or 4 samples in downsampled)
if (durationPoints >= 4 && currentMaxLean >= 16.0) {
const entrySpeed = points[startIndex].speed;
const apexSpeed = points[apexIdx].speed;
const exitSpeed = points[endIndex].speed;
// Grading logic
let grade: 'A+' | 'A' | 'B' | 'C' | 'D' = 'B';
const speedRetention = entrySpeed > 0 ? (apexSpeed / entrySpeed) : 1;
const speedExitDelta = exitSpeed - apexSpeed;
if (currentMaxLean >= 35 && speedRetention >= 0.82 && speedExitDelta >= 0) {
grade = 'A+';
} else if (currentMaxLean >= 28 && speedRetention >= 0.75) {
grade = 'A';
} else if (currentMaxLean >= 20) {
grade = 'B';
} else if (speedRetention < 0.6) {
grade = 'D';
} else {
grade = 'C';
}
corners.push({
id: corners.length + 1,
direction: cornerDir,
entrySpeed: Math.round(entrySpeed),
apexSpeed: Math.round(apexSpeed),
exitSpeed: Math.round(exitSpeed),
apexLeanAngle: Math.round(currentMaxLean),
grade,
startIndex,
apexIndex: apexIdx,
endIndex,
});
}
inCorner = false;
currentMaxLean = 0;
}
}
}
return corners;
}
/** Returns recorded telemetry only. Historical name retained for callers; no synthetic route. */
export function synthesizeTelemetryTrack(ride: Ride): TelemetryPoint[] {
if (ride.points?.length) return normalizeTelemetryPoints(ride.points);
if (ride.gpxContent) {
const points = parseGpxToTelemetryPoints(ride.gpxContent);
if (points.length) return points;
}
if (ride.csvContent) return parseCsvToTelemetryPoints(ride.csvContent);
return [];
}