import type { TelemetryPoint } from './telemetry-export'; const numeric = (value: unknown): number | undefined => { if (value == null || value === '') return undefined; const n = Number(value); return Number.isFinite(n) ? n : undefined; }; /** Recorded samples only. Invalid GPS, explicit segments and time gaps break geometry. */ export function normalizeTelemetryPoints(samples: unknown[]): TelemetryPoint[] { const valid: TelemetryPoint[] = []; let sourceSegment = 0; let lastExplicitSegment: number | undefined; for (const sample of samples) { if (!sample || typeof sample !== 'object') { sourceSegment++; continue; } const p = sample as Record; const latitude = numeric(p.latitude), longitude = numeric(p.longitude); const time = typeof p.timestamp === 'string' ? Date.parse(p.timestamp) : NaN; if (latitude == null || longitude == null || Math.abs(latitude) > 90 || Math.abs(longitude) > 180 || !Number.isFinite(time)) { sourceSegment++; continue; } const value = (key: string) => numeric(p[key]) ?? 0; const explicitSegment = numeric(p.segmentId); if (valid.length && explicitSegment !== lastExplicitSegment) sourceSegment++; lastExplicitSegment = explicitSegment; valid.push({ timestamp: new Date(time).toISOString(), latitude, longitude, altitude: value('altitude'), speed: value('speed'), heading: value('heading'), gpsAccuracy: value('gpsAccuracy'), leanAngle: value('leanAngle'), pitchAngle: value('pitchAngle'), accelX: value('accelX'), accelY: value('accelY'), accelZ: value('accelZ'), longitudinalG: value('longitudinalG'), lateralG: value('lateralG'), segmentId: sourceSegment }); } valid.sort((a,b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); let segment = 0; return valid.map((point, i) => { const previous = valid[i-1]; if (previous && (previous.segmentId !== point.segmentId || Date.parse(point.timestamp) - Date.parse(previous.timestamp) > 5000)) segment++; return {...point, segmentId:segment}; }); } export function areTelemetryPointsConnected(a: TelemetryPoint, b: TelemetryPoint): boolean { const delta = Date.parse(b.timestamp) - Date.parse(a.timestamp); return a.segmentId === b.segmentId && delta >= 0 && delta <= 5000; } /** * Advances a replay cursor by a budget of virtual milliseconds, honoring the real time * between recorded samples so the rider marker stays synchronized with the elapsed-time * readout (which is derived from timestamps). Playback is therefore wall-clock accurate at * 1x and correctly scaled at other speeds, even when samples are non-uniform. * * Disconnected transitions (segment breaks or GPS loss, per areTelemetryPointsConnected) * are compressed to a single nominal 20 Hz step (50 ms) so playback never stalls across * dead time instead of waiting out a multi-second gap. No coordinates are altered. * * Returns the new index and the leftover budget to carry into the next frame. */ export function advancePlaybackIndex( points: TelemetryPoint[], fromIndex: number, budgetMs: number ): { index: number; remainingMs: number } { if (points.length === 0) return { index: 0, remainingMs: 0 }; let i = Math.max(0, Math.min(fromIndex, points.length - 1)); let budget = Math.max(0, budgetMs); while (i < points.length - 1) { const connected = areTelemetryPointsConnected(points[i], points[i + 1]); const dt = connected ? Math.max(0, Date.parse(points[i + 1].timestamp) - Date.parse(points[i].timestamp)) : 50; if (budget >= dt) { budget -= dt; i++; } else { break; } } return { index: i, remainingMs: i < points.length - 1 ? budget : 0 }; } /** Leaflet's geographic API is [latitude, longitude], never normalized screen pixels. */ export function geographicSegments(points: TelemetryPoint[]): [number, number][][] { const segments: [number,number][][] = []; points.forEach((point,i) => { if (!i || !areTelemetryPointsConnected(points[i-1],point)) segments.push([]); segments[segments.length-1].push([point.latitude,point.longitude]); }); return segments; } function limitPoints(points: TelemetryPoint[], maxPoints: number): TelemetryPoint[] { if (points.length <= maxPoints || !Number.isFinite(maxPoints)) return points; const stride = Math.max(1, Math.ceil(points.length / Math.max(2,maxPoints))); // Retain segment endpoints as well as regular samples; no interpolation or snapping. return points.filter((p,i) => i === 0 || i === points.length-1 || i % stride === 0 || !areTelemetryPointsConnected(points[i-1],p) || !areTelemetryPointsConnected(p,points[i+1])); } export function parseGpxToTelemetryPoints(xml: string, maxPoints = Infinity): TelemetryPoint[] { if (!xml || typeof xml !== 'string') return []; const samples: unknown[] = []; const prefix = '(?:[\\w.-]+:)?'; const segments = [...xml.matchAll(new RegExp(`<${prefix}trkseg\\b[^>]*>([\\s\\S]*?)<\\/${prefix}trkseg\\s*>`, 'gi'))]; const blocks = segments.length ? segments.map(m=>m[1]) : [xml]; blocks.forEach((block,segmentId) => { const regex = new RegExp(`<${prefix}trkpt\\b([^>]*?)(?:\\/>|>([\\s\\S]*?)<\\/${prefix}trkpt\\s*>)`, 'gi'); for (const match of block.matchAll(regex)) { const attr = (name: string) => new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`,'i').exec(match[1])?.[1]; const inner = (name: string) => new RegExp(`<${prefix}${name}\\b[^>]*>([\\s\\S]*?)<\\/${prefix}${name}\\s*>`,'i').exec(match[2] || '')?.[1].trim(); samples.push({ latitude:attr('lat'), longitude:attr('lon'), timestamp:inner('time'), altitude:inner('ele'), speed:inner('speed'), heading:inner('heading'), leanAngle:inner('lean'), longitudinalG:inner('longitudinalG'), lateralG:inner('lateralG'), segmentId }); } }); return limitPoints(normalizeTelemetryPoints(samples),maxPoints); } export function parseCsvToTelemetryPoints(csv: string, maxPoints = Infinity): TelemetryPoint[] { if (!csv || typeof csv !== 'string') return []; // MotorSignal's numeric export has no embedded commas; tolerate quoted scalar cells. const rows = csv.trim().split(/\r?\n/).map(line=>line.split(',').map(cell=>cell.trim().replace(/^"|"$/g,''))); if (rows.length < 2) return []; const names = rows[0].map(h=>h.toLowerCase().replace(/^\uFEFF/,'')); const fields = ['timestamp','latitude','longitude','altitude','speed','heading','gpsAccuracy','leanAngle','pitchAngle','accelX','accelY','accelZ','longitudinalG','lateralG','segmentId']; let segmentId = 0; const samples = rows.slice(1).map(row => { const sample: Record = {segmentId}; fields.forEach(field => { const i=names.indexOf(field.toLowerCase()); if(i>=0) sample[field]=row[i]; }); if (numeric(sample.latitude) == null || numeric(sample.longitude) == null) segmentId++; return sample; }); return limitPoints(normalizeTelemetryPoints(samples),maxPoints); }