134 lines
6.8 KiB
TypeScript
134 lines
6.8 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown> = {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);
|
|
}
|