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 []; }