Files
promptstory/utils/gpxUtils.ts
2026-02-15 19:11:08 +01:00

269 lines
9.8 KiB
TypeScript

import { ActivityStats } from '../types';
interface GpxAnalysisResult {
stats: ActivityStats;
richSummary: string;
}
export const parseGpxFile = async (file: File): Promise<GpxAnalysisResult> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target?.result as string;
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(text, 'text/xml');
// Obsługa różnych wariantów tagu 'trkpt' (z namespace lub bez)
let trkpts = Array.from(xmlDoc.getElementsByTagName('trkpt'));
if (trkpts.length === 0) {
// Spróbuj znaleźć po localName, jeśli namespace'y przeszkadzają
const allElements = xmlDoc.getElementsByTagName('*');
trkpts = Array.from(allElements).filter(el => el.localName === 'trkpt');
}
if (trkpts.length === 0) {
throw new Error('Nie znaleziono punktów śledzenia (trkpt) w pliku GPX.');
}
// --- ZMIENNE AGREGUJĄCE ---
let totalDistanceKm = 0;
let totalElevationGain = 0;
let maxElevation = -Infinity;
let minElevation = Infinity;
let startTime: Date | null = null;
let endTime: Date | null = null;
// Heart Rate & Cadence
let totalHr = 0;
let hrCount = 0;
let maxHr = 0;
let totalCadence = 0;
let cadenceCount = 0;
// Splits (KM) Logic
const splits: { km: number; timeMs: number; avgHr: number; elevGain: number }[] = [];
let currentSplitDistance = 0;
let currentSplitTimeStart = 0;
let currentSplitHrSum = 0;
let currentSplitHrCount = 0;
let currentSplitElevGain = 0;
let splitIndex = 1;
let lastLat: number | null = null;
let lastLon: number | null = null;
let lastEle: number | null = null;
// Helper to get text content from child nodes loosely
const getChildText = (parent: Element, tagName: string): string | null => {
let el = parent.getElementsByTagName(tagName)[0];
if (!el) {
// Try finding by localName if strictly not found
for(let i=0; i<parent.children.length; i++) {
if(parent.children[i].localName === tagName) {
el = parent.children[i];
break;
}
}
}
return el ? el.textContent : null;
};
// --- ITERACJA PO PUNKTACH ---
for (let i = 0; i < trkpts.length; i++) {
const pt = trkpts[i];
const lat = parseFloat(pt.getAttribute('lat') || '0');
const lon = parseFloat(pt.getAttribute('lon') || '0');
const eleStr = getChildText(pt, 'ele');
const ele = eleStr ? parseFloat(eleStr) : NaN;
const timeStr = getChildText(pt, 'time');
// Extensions search for HR/Cadence
// Przeszukujemy całe poddrzewo punktu w poszukiwaniu tagów hr/cad, bo struktura extensions bywa różna
const allChilds = pt.getElementsByTagName('*');
let hr = 0;
let cad = 0;
for (let j = 0; j < allChilds.length; j++) {
const node = allChilds[j];
const name = node.localName.toLowerCase();
if (name === 'hr' || name === 'heartrate') {
const val = parseInt(node.textContent || '0');
if (!isNaN(val) && val > 0) hr = val;
}
if (name === 'cad' || name === 'cadence') {
const val = parseInt(node.textContent || '0');
if (!isNaN(val) && val > 0) cad = val;
}
}
if (hr > 0) {
totalHr += hr;
hrCount++;
if (hr > maxHr) maxHr = hr;
currentSplitHrSum += hr;
currentSplitHrCount++;
}
if (cad > 0) {
totalCadence += cad;
cadenceCount++;
}
let currentTime = 0;
if (timeStr) {
const d = new Date(timeStr);
currentTime = d.getTime();
if (!startTime) {
startTime = d;
currentSplitTimeStart = currentTime;
}
endTime = d;
}
// Elevation Min/Max
if (!isNaN(ele)) {
if (ele > maxElevation) maxElevation = ele;
if (ele < minElevation) minElevation = ele;
}
// Distance & Calculation
if (lastLat !== null && lastLon !== null) {
const distKm = getDistanceFromLatLonInKm(lastLat, lastLon, lat, lon);
totalDistanceKm += distKm;
currentSplitDistance += distKm;
// Elevation Gain
if (lastEle !== null && !isNaN(ele) && ele > lastEle) {
const gain = ele - lastEle;
// Ignorujemy mikro-zmiany (szum GPS) poniżej 0.5m
if (gain > 0.2) {
totalElevationGain += gain;
currentSplitElevGain += gain;
}
}
// --- SPLIT LOGIC (Co 1 KM) ---
if (currentSplitDistance >= 1.0) {
const splitTimeMs = currentTime - currentSplitTimeStart;
splits.push({
km: splitIndex,
timeMs: splitTimeMs,
avgHr: currentSplitHrCount > 0 ? Math.round(currentSplitHrSum / currentSplitHrCount) : 0,
elevGain: Math.round(currentSplitElevGain)
});
// Reset Split
splitIndex++;
currentSplitDistance = currentSplitDistance - 1.0;
currentSplitTimeStart = currentTime;
currentSplitHrSum = 0;
currentSplitHrCount = 0;
currentSplitElevGain = 0;
}
}
lastLat = lat;
lastLon = lon;
lastEle = isNaN(ele) ? lastEle : ele;
}
// --- PODSUMOWANIE ---
const totalDurationMs = startTime && endTime ? (endTime.getTime() - startTime.getTime()) : 0;
const avgHr = hrCount > 0 ? Math.round(totalHr / hrCount) : 0;
const avgCadence = cadenceCount > 0 ? Math.round(totalCadence / cadenceCount) : 0;
const avgPaceMs = totalDistanceKm > 0 ? totalDurationMs / totalDistanceKm : 0; // ms per km
// Formatowanie statystyk do UI
const uiStats: ActivityStats = {
distance: `${totalDistanceKm.toFixed(2)} km`,
duration: msToTime(totalDurationMs),
elevation: `${Math.round(totalElevationGain)}m (Max: ${Math.round(maxElevation === -Infinity ? 0 : maxElevation)}m)`
};
// --- BUDOWANIE BOGATEGO RAPORTU DLA AI ---
let report = `=== SZCZEGÓŁOWY RAPORT Z PLIKU GPX ===\n`;
report += `PODSUMOWANIE:\n`;
report += `- Dystans Całkowity: ${totalDistanceKm.toFixed(2)} km\n`;
report += `- Czas Całkowity: ${msToTime(totalDurationMs)}\n`;
report += `- Średnie Tempo: ${formatPace(avgPaceMs)} min/km\n`;
report += `- Przewyższenia: +${Math.round(totalElevationGain)}m / Max Wysokość: ${Math.round(maxElevation === -Infinity ? 0 : maxElevation)}m\n`;
if (avgHr > 0) {
report += `- Tętno: Średnie ${avgHr} bpm / Max ${maxHr} bpm\n`;
}
if (avgCadence > 0) {
report += `- Kadencja: ${avgCadence} spm\n`;
}
if (splits.length > 0) {
report += `\nANALIZA MIĘDZYCZASÓW (SPLITS - CO 1 KM):\n`;
splits.forEach(s => {
const pace = formatPace(s.timeMs); // time for 1km = pace
const hrStr = s.avgHr > 0 ? `, HR: ${s.avgHr} bpm` : '';
const elevStr = s.elevGain > 5 ? `, Elev: +${s.elevGain}m` : ''; // show only significant gain
report += `Km ${s.km}: ${pace} min/km${hrStr}${elevStr}\n`;
});
} else {
report += `\n(Brak pełnych kilometrów do analizy międzyczasów)\n`;
}
// Zakończenie
report += `\nWSKAZÓWKI DLA AI: Wykorzystaj te dane do opisu walki na trasie. Zauważ momenty kryzysu (zwolnienie tempa, wysokie tętno) oraz momenty "puszczenia" (zbiegi, przyspieszenie).`;
resolve({
stats: uiStats,
richSummary: report
});
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('Błąd odczytu pliku'));
reader.readAsText(file);
});
};
// --- POMOCNICZE FUNKCJE MATEMATYCZNE ---
function getDistanceFromLatLonInKm(lat1: number, lon1: number, lat2: number, lon2: number) {
const R = 6371; // Radius of the earth in km
const dLat = deg2rad(lat2 - lat1);
const dLon = deg2rad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const d = R * c;
return d;
}
function deg2rad(deg: number) {
return deg * (Math.PI / 180);
}
function msToTime(duration: number) {
if (duration <= 0) return "0h 00m";
const minutes = Math.floor((duration / (1000 * 60)) % 60);
const hours = Math.floor((duration / (1000 * 60 * 60)) % 24);
// Jeśli mniej niż godzina, pokaż minuty i sekundy (opcjonalnie, tu zostawiamy format h m)
if (hours === 0) return `${minutes}m`;
return `${hours}h ${minutes}m`;
}
function formatPace(msPerKm: number): string {
if (msPerKm <= 0 || !isFinite(msPerKm)) return "-:--";
const totalSeconds = Math.floor(msPerKm / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}