import { ActivityStats } from '../types'; interface GpxAnalysisResult { stats: ActivityStats; richSummary: string; } export const parseGpxFile = async (file: File): Promise => { 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')}`; }