MotorSignal-App/website/lib/telemetry-track.ts

99 lines
5.4 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;
}
/** 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);
}