244 lines
9.1 KiB
TypeScript
244 lines
9.1 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');
|
|
|
|
const trkpts = Array.from(xmlDoc.getElementsByTagName('trkpt'));
|
|
|
|
if (trkpts.length === 0) {
|
|
// Fallback logic could be added here for routes without time, but for storytelling we focus on tracks
|
|
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;
|
|
let lastTime: number | null = 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 ele = parseFloat(pt.getElementsByTagName('ele')[0]?.textContent || '0');
|
|
const timeStr = pt.getElementsByTagName('time')[0]?.textContent;
|
|
|
|
// Extensions (HR / Cadence)
|
|
// Note: Namespaces can vary (ns3:hr, gpxtpx:hr), so we search loosely or by standard tag names inside extensions
|
|
const extensions = pt.getElementsByTagName('extensions')[0];
|
|
let hr = 0;
|
|
let cad = 0;
|
|
|
|
if (extensions) {
|
|
// Try standard Garmin/GPX schemes
|
|
const hrNode = extensions.getElementsByTagName('gpxtpx:hr')[0] || extensions.getElementsByTagName('ns3:hr')[0] || extensions.getElementsByTagName('hr')[0];
|
|
const cadNode = extensions.getElementsByTagName('gpxtpx:cad')[0] || extensions.getElementsByTagName('ns3:cad')[0] || extensions.getElementsByTagName('cad')[0];
|
|
|
|
if (hrNode?.textContent) {
|
|
hr = parseInt(hrNode.textContent);
|
|
if (!isNaN(hr) && hr > 0) {
|
|
totalHr += hr;
|
|
hrCount++;
|
|
if (hr > maxHr) maxHr = hr;
|
|
currentSplitHrSum += hr;
|
|
currentSplitHrCount++;
|
|
}
|
|
}
|
|
if (cadNode?.textContent) {
|
|
cad = parseInt(cadNode.textContent);
|
|
if (!isNaN(cad) && 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;
|
|
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 = 0; // or subtract 1.0 specifically for precision, but reset is safer for GPS drift
|
|
currentSplitTimeStart = currentTime;
|
|
currentSplitHrSum = 0;
|
|
currentSplitHrCount = 0;
|
|
currentSplitElevGain = 0;
|
|
}
|
|
}
|
|
|
|
lastLat = lat;
|
|
lastLon = lon;
|
|
lastEle = isNaN(ele) ? lastEle : ele;
|
|
lastTime = currentTime;
|
|
}
|
|
|
|
// --- 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)}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)}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')}`;
|
|
}
|