Ultra Route Planner
Chcesz przygotować się do wielokilometrowego i wielodniowego ultramaratonu rowerowego? Ta aplikacja pomoże Ci wstępnie oszacować czasy przejazdu, zapotrzebowanie kaloryczne oraz podział trasy na dni.
Co potrafi aplikacja?
- Wprowadzasz dane roweru i zawodnika:
- masa rowerzysty i roweru z bagażem,
- rozmiar opon, przełożenia (korba i kaseta),
- preferowana kadencja i limit dni na trasę.
- Dodajesz odcinki trasy:
- długość (km),
- średnie nachylenie (%),
- typ nawierzchni (asfalt, gravel, offroad),
- własne notatki (np. „długi podjazd”, „szutrowy odcinek”).
- Symulacja jazdy:
- na podstawie przełożeń i kadencji obliczana jest prędkość,
- uwzględnia opory toczenia, aerodynamiczne oraz masę całkowitą,
- generuje przewidywany czas przejazdu i spalanie kalorii dla każdego odcinka.
- Planowanie 7-dniowe:
- aplikacja automatycznie rozbija trasę na dni,
- podaje średnią liczbę kilometrów i godzin jazdy dziennie,
- sugeruje optymalny rytm jazdy i odpoczynku.
- Eksport do CSV:
- możesz pobrać dane do arkusza kalkulacyjnego i analizować szczegóły,
- łatwo udostępnisz plan swoim znajomym albo trenerowi.
Jak zacząć?
- Wypełnij dane roweru i zawodnika.
- Dodaj odcinki trasy lub zostaw pustą tabelę, a aplikacja sama rozdzieli dystans na dni.
- Kliknij Oblicz plan i sprawdź wyniki!
- (opcjonalnie) Eksportuj CSV i zapisz dane.
To jest model przybliżony — służy do orientacyjnego planowania. Rzeczywiste warunki (pogoda, nawierzchnia, zmęczenie, sen, regeneracja) mogą znacznie zmienić osiągi. Traktuj wyniki jako narzędzie pomocnicze, a nie pewną prognozę.
Podział trasy GPX na dni
Wczytywanie danych
- Wczytaj plik
.gpx:- kliknij „Wczytaj plik GPX” i wybierz plik z dysku, lub
- wklej URL do pliku GPX i kliknij „Wczytaj URL”.
- Po wczytaniu trasy ustaw jeden z parametrów:
- Liczbę dni (np. 7) — trasa zostanie podzielona według wybranego modelu,
- km/dzień (np. 80) — trasa zostanie cięta po określonej długości, ignorując modele.
- W przypadku podziału na dni wybierz model podziału obciążenia:
- Równy podział — wszystkie dni mają tę samą długość,
- Zmęczeniowy — każdy kolejny dzień jest krótszy (model narastającego zmęczenia),
- Krzywa formy — dłuższy początek i koniec, krótszy środek (naturalna „krzywa formy”),
- Sportowy — stopniowe wydłużanie etapów, „rozkręcanie się” w trakcie trasy.
- W przypadku modelu sportowego wybierz intensywność
- Kliknij „Podziel trasę”.
Podział trasy
- Długość całej trasy obliczana jest na podstawie odległości haversine między kolejnymi punktami GPX.
- Punkty cięcia między dniami wyznaczane są interpolacyjnie, dzięki czemu odcinki odpowiadają dokładnie założonym parametrom (nawet jeśli punkty GPX są rzadko rozmieszczone).
- W zależności od wybranej metody:
1. Podział równy
Odcinki dzielone są proporcjonalnie:
całkowita długość / liczba dni.
2. Podział po km/dzień
Trasa cięta jest w odstępach stałej długości, np. co 80 km.
3. Podział wagowy (trzy modele)
Każdy dzień otrzymuje wagę opisującą jego „trudność”.
Dystans każdego dnia = (waga / suma wag) × całkowita długość.
Modele:
- Zmęczeniowy – każdy kolejny dzień ma nieco mniejszą wagę (realistyczne narastające zmęczenie).
- Krzywa formy – najkrótszy środek, dłuższy początek i koniec (naturalny profil na długich wyprawach).
- Sportowy – każdy kolejny dzień ma coraz większą wagę, w zależności od intensywności (coraz dłuższe odcinki).
Wynik
- Trasa jest dzielona na segmenty oznaczone różnymi kolorami.
- Każdy dzień wyświetla:
- długość odcinka,
- współrzędne startu i końca,
- pliki do pobrania:
- GPX dla dnia,
- GeoJSON dla dnia.
- Segmenty można kliknąć na mapie, aby zobaczyć przebieg.
Tabela segmentów
- Pokazuje dystans, współrzędne startu i końca każdego dnia.
- Umożliwia pobranie odcinków w formacie GPX lub GeoJSON.
Eksport segmentów
- Każdy odcinek można zapisać jako osobny plik:
Dzień_1.gpx,Dzień_2.gpx, …Dzień_1.geojson,Dzień_2.geojson, …
Przykład użycia
Dla trasy o długości 1200 km:
- Podział na 7 dni da etapy ~171 km dziennie,
- Można też ustawić np.
km/dzień = 150, wtedy powstanie 8 odcinków.
- Aplikacja nie bierze pod uwagę wysokości (elewacji) ani rodzaju drogi — jedynie dystans po współrzędnych.
- Interpolacja między punktami jest liniowa (po współrzędnych geograficznych).
- Wszystkie obliczenia wykonują się w przeglądarce, bez połączenia z siecią.
Kalkulator planu trasy
Wprowadź parametry roweru i zawodnika oraz odcinki trasy. Aplikacja szacuje prędkość wynikającą z przełożeń i kadencji oraz moc/kalorie i czasy przejazdów. To model przybliżony — traktuj jako plan pomocniczy.
Parametry zawodnika / roweru
Przełożenia
Dodaj odcinek trasy
Szybkie ustawienia modelu
Odcinki trasy
| # | Nazwa | km | pochylenie(%) | nawierzchnia | uwagi | akcje |
|---|
Wyniki
GPX Splitter – Podział trasy GPX na dni
Łatwe dzielenie długich tras GPX na etapy — wczytaj plik lub URL, ustaw liczbę dni lub km/dzień, zobacz segmenty na mapie i pobierz każdy odcinek jako GPX lub GeoJSON.
Kod po stronie przeglądarki
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/leaflet.fullscreen.css" />
<link type="text/css" href="http://www.dariuszrorat.ugu.pl/assets/css/bootstrap/wcag-outline.min.css" rel="stylesheet">
<style>
#map { height: 60vh; min-height: 400px; }
.small-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace; font-size: .85rem; }
#app h2 {
border-bottom: 1px solid var(--bs-border-color);
margin-top: 0;
margin-bottom: 0.5em;
padding-bottom: 0.3em;
}
</style>
<div id="app">
<h2>Kalkulator planu trasy</h2>
<p class="small text-muted">Wprowadź parametry roweru i zawodnika oraz odcinki trasy. Aplikacja szacuje prędkość wynikającą z przełożeń i kadencji oraz moc/kalorie i czasy przejazdów. To model przybliżony — traktuj jako plan pomocniczy.</p>
<div class="row g-3">
<div class="col-md-6">
<div class="card p-3">
<h3 class="h5">Parametry zawodnika / roweru</h3>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="massRider">Masa rowerzysty (kg)</label>
<div class="col-sm-6"><input id="massRider" class="form-control" type="number" value="90"></div>
</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="massBike">Masa roweru + bagaż (kg)</label>
<div class="col-sm-6"><input id="massBike" class="form-control" type="number" value="25"></div>
</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="tireSize">Rozmiar opon (np. 700x38)</label>
<div class="col-sm-6"><input id="tireSize" class="form-control" type="text" value="700x38"></div>
</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="cadence">Kadencja docelowa (rpm)</label>
<div class="col-sm-6"><input id="cadence" class="form-control" type="number" value="65"></div>
</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="cadenceMax">Max kadencja (rpm)</label>
<div class="col-sm-6"><input id="cadenceMax" class="form-control" type="number" value="70"></div>
</div>
<hr>
<h4 class="h5">Przełożenia</h4>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="chainrings">Korba (zęby, wpisuj przecinkami)</label>
<div class="col-sm-6"><input id="chainrings" class="form-control" value="28,38,48"></div>
</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="cassette">Kaseta (zęby, przecinkami, np. 28,24,21,18,16,14)</label>
<div class="col-sm-6"><input id="cassette" class="form-control" value="28,24,21,18,16,14"></div>
</div>
<hr>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="days">Limit dni (np. 7)</label>
<div class="col-sm-6"><input id="days" class="form-control" type="number" value="7"></div>
</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="totalKm">Celny dystans całkowity (km)</label>
<div class="col-sm-6"><input id="totalKm" class="form-control" type="number" value="1183"></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card p-3">
<h3 class="h5">Dodaj odcinek trasy</h3>
<div class="mb-2">
<label class="form-label" for="segName">Nazwa odcinka</label>
<input id="segName" class="form-control" placeholder="np. Wisła → Ustroń">
</div>
<div class="mb-2 row">
<div class="col-sm-6"><label class="form-label" for="segDist">Długość (km)</label><input id="segDist" class="form-control" type="number" value="50"></div>
<div class="col-sm-6"><label class="form-label" for="segGrade">Śr. pochylenie (%)</label><input id="segGrade" class="form-control" type="number" value="1"></div>
</div>
<div class="mb-2 row">
<div class="col-sm-6">
<label class="form-label" for="segSurface">Rodzaj nawierzchni</label>
<select id="segSurface" class="form-select">
<option value="asfalt" selected>Asfalt</option>
<option value="gravel">Szuter / gravel</option>
<option value="offroad">Offroad / ścieżka</option>
</select>
</div>
<div class="col-sm-6"><label class="form-label" for="segNote">Uwagi / tempo</label><input id="segNote" class="form-control" placeholder="np. długi podjazd"></div>
</div>
<div class="d-flex gap-2">
<button id="addSeg" class="btn btn-primary">Dodaj odcinek</button>
<button id="clearSegs" class="btn btn-outline-secondary">Wyczyść</button>
</div>
</div>
<div class="card p-3 mt-3">
<h3 class="h5">Szybkie ustawienia modelu</h3>
<div class="mb-2 small-muted">Czynniki modelu wpływające na prędkość i opory</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="cda">CdA (upright)</label>
<div class="col-sm-6"><input id="cda" class="form-control" type="number" step="0.01" value="0.50"></div>
</div>
<div class="mb-2 row">
<label class="col-sm-6 col-form-label" for="efficiency">Sprawność pedałowania</label>
<div class="col-sm-6"><input id="efficiency" class="form-control" type="number" step="0.01" value="0.24"></div>
</div>
</div>
</div>
</div>
<hr>
<h4 class="mt-3">Odcinki trasy</h4>
<div class="table-responsive">
<table id="segmentsTable" class="table table-striped table-sm">
<thead>
<tr><th>#</th><th>Nazwa</th><th>km</th><th>pochylenie(%)</th><th>nawierzchnia</th><th>uwagi</th><th>akcje</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="d-flex gap-2 mb-4">
<button id="compute" class="btn btn-success">Oblicz plan</button>
<button id="exportCsv" class="btn btn-outline-primary">Eksport CSV</button>
</div>
<div id="results" class="card p-3">
<h3 class="h5">Wyniki</h3>
<div id="resultsBody">Brak obliczeń.</div>
</div>
<h2 class="mt-3">GPX Splitter – Podział trasy GPX na dni</h2>
<p class="small text-muted">
Łatwe dzielenie długich tras GPX na etapy — wczytaj plik lub URL, ustaw liczbę dni lub km/dzień, zobacz segmenty na mapie i pobierz każdy odcinek jako GPX lub GeoJSON.
</p>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="gpxFile" class="form-label">Wczytaj plik GPX</label>
<input id="gpxFile" type="file" accept=".gpx" class="form-control"/>
</div>
<div class="col-md-6">
<label for="gpxUrl" class="form-label">Lub URL do GPX</label>
<div class="input-group">
<input id="gpxUrl" class="form-control" placeholder="https://.../route.gpx">
<button id="loadUrlBtn" class="btn btn-outline-secondary">Wczytaj URL</button>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3">
<label for="daysInput" class="form-label">Liczba dni</label>
<input id="daysInput" type="number" min="1" value="7" class="form-control"/>
</div>
<div class="col-md-3">
<label for="weightModel" class="form-label">Model wagowy</label>
<select id="weightModel" class="form-select">
<option value="equal">równy podział</option>
<option value="fatigue">zmęczeniowy</option>
<option value="ushape">krzywa formy</option>
<option value="sport">sportowy</option>
</select>
</div>
<div class="col-md-3">
<label for="intensity" class="form-label">Intensywność (sport)</label>
<select id="intensity" class="form-select" disabled>
<option value="0.1">lekki</option>
<option value="0.2">średni</option>
<option value="0.3">ciężki</option>
</select>
</div>
<div class="col-md-3">
<label for="kmPerDayInput" class="form-label">Lub km/dzień (opcj.)</label>
<input id="kmPerDayInput" type="number" min="1" placeholder="np. 80" class="form-control"/>
</div>
</div>
<div class="mb-3">
<div class="btn btn-primary me-2" id="processBtn">Podziel trasę</div>
<div class="btn btn-secondary" id="fitMapBtn">Dopasuj mapę</div>
<div class="form-text">Wczytaj GPX, kliknij „Podziel trasę”. Możesz zmienić liczbę dni lub podać km/dzień.</div>
</div>
<div id="map" class="mb-3"></div>
<div id="summary" class="mb-4"></div>
<div id="segmentsTable2"></div>
<div class="mt-4 small text-muted">
Uwaga: cięcie trasy interpoluje punkty liniowo między kolejnymi punktami trasy (dokładność wystarczająca dla planowania wędrówki).
</div>
</div>
<!-- Leaflet -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/Leaflet.fullscreen.min.js"></script>
<!-- togeojson (GPX -> GeoJSON) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/togeojson/0.16.0/togeojson.min.js"></script>
<script>
// ====== Utility ======
function parseList(s){ if(!s) return []; return s.split(',').map(x=>parseFloat(x.trim())).filter(x=>!isNaN(x)).sort((a,b)=>a-b); }
function tireDiameterFromText(txt){
// Supports "700x38" or "28x622" style. Returns diameter in meters
if(!txt) return 0.698;
const m = txt.match(/(\d{3,4})\s*[x×]\s*(\d{1,3})/);
if(m){
const beadNominal = (parseInt(m[1]) >= 700) ? 0.622 : (parseInt(m[1])/1000 - 2*0.02);
const tyreHeight = parseInt(m[2])/1000; // mm to m
return beadNominal + 2*tyreHeight;
}
// fallback
return 0.698;
}
function addRowToTable(idx, seg){
const tbody = document.querySelector('#segmentsTable tbody');
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx}</td><td>${escapeHtml(seg.name)}</td><td class="nowrap">${seg.dist}</td><td>${seg.grade}</td><td>${seg.surface}</td><td>${escapeHtml(seg.note||'')}</td><td><button class="btn btn-sm btn-danger remove">Usuń</button></td>`;
tbody.appendChild(tr);
tr.querySelector('.remove').addEventListener('click',()=>{ tr.remove(); refreshIndices(); segments.splice(idx-1,1); });
}
function refreshIndices(){ const rows = document.querySelectorAll('#segmentsTable tbody tr'); rows.forEach((r,i)=> r.children[0].innerText = i+1); }
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":"'"}[c]; }); }
let segments = [];
// ====== UI handlers ======
document.getElementById('addSeg').addEventListener('click', ()=>{
const name = document.getElementById('segName').value.trim() || `Odcinek ${segments.length+1}`;
const dist = parseFloat(document.getElementById('segDist').value) || 0;
const grade = parseFloat(document.getElementById('segGrade').value) || 0;
const surface = document.getElementById('segSurface').value;
const note = document.getElementById('segNote').value || '';
const seg = {name, dist, grade, surface, note};
segments.push(seg);
document.querySelector('#segmentsTable tbody').innerHTML='';
segments.forEach((s,i)=> addRowToTable(i+1,s));
});
document.getElementById('clearSegs').addEventListener('click', ()=>{ segments=[]; document.querySelector('#segmentsTable tbody').innerHTML=''; });
// ====== Core model ======
function computeModel(){
const rider = parseFloat(document.getElementById('massRider').value) || 75;
const bike = parseFloat(document.getElementById('massBike').value) || 15;
const totalMass = rider + bike; // kg
const tire = document.getElementById('tireSize').value;
const D = tireDiameterFromText(tire); // m
const circ = Math.PI * D; // m
const cadence = Math.min(parseFloat(document.getElementById('cadence').value)||60, parseFloat(document.getElementById('cadenceMax').value)||90);
const chainrings = parseList(document.getElementById('chainrings').value);
const cassette = parseList(document.getElementById('cassette').value);
const cda = parseFloat(document.getElementById('cda').value) || 0.5;
const efficiency = parseFloat(document.getElementById('efficiency').value) || 0.24;
// C_rr by surface
const Crrmap = {asfalt:0.004, gravel:0.01, offroad:0.02};
const rho = 1.225; const g=9.81;
const results = {segments:[], totalKm:0, totalTime_h:0, totalCalories:0};
// If no chainrings/cassette provided, use reasonable defaults
const defaultChainrings = chainrings.length?chainrings:[28,38,48];
const defaultCassette = cassette.length?cassette:[28,24,21,18,16,14];
if(segments.length===0){
// build default uniform segment based on totalKm
const totalKm = parseFloat(document.getElementById('totalKm').value) || 1183;
const days = parseInt(document.getElementById('days').value) || 7;
const perDay = Math.max(1, Math.round(totalKm / days));
for(let i=0;i<days;i++) segments.push({name:`Dzień ${i+1}`, dist: perDay, grade:0, surface:'asfalt', note:''});
}
for(let i=0;i<segments.length;i++){
const s = segments[i];
// choose gearing heuristically
const uphill = s.grade > 3;
const downhill = s.grade < -3;
let chosenChain, chosenCass;
if(uphill){ chosenChain = Math.min(...defaultChainrings); chosenCass = Math.max(...defaultCassette); }
else if(downhill){ chosenChain = Math.max(...defaultChainrings); chosenCass = Math.min(...defaultCassette); }
else { // flat: choose middle
chosenChain = defaultChainrings[Math.floor(defaultChainrings.length/2)] || defaultChainrings[0];
chosenCass = defaultCassette[Math.floor(defaultCassette.length/2)] || defaultCassette[0];
}
const gearRatio = chosenChain / chosenCass;
const v_calc = cadence * circ * gearRatio / 60; // m/s theoretical from cadence and gearing
// Determine vmax_kmh based on mass, wheel size and CdA (simple heuristic)
const massFactor = Math.max(0.5, 1 - (totalMass-70)/220); // heavier -> mniejszy vmax
const wheelFactor = Math.max(0.8, D/0.7);
const postureFactor = Math.max(0.4, 1 - (cda - 0.28));
const baseVmax = 40; // km/h for an upright touring bike with heavy load in good conditions
const vmax_kmh = baseVmax * massFactor / wheelFactor * postureFactor;
let v_m_s = v_calc;
if((v_calc*3.6) > vmax_kmh) v_m_s = vmax_kmh/3.6;
// If extremely slow (e.g., very steep or gear mismatch), allow walking speed ~5 km/h
if(v_m_s < 0.5) v_m_s = 0.5; // 0.5 m/s ~1.8 km/h (very slow)
const gradeDecimal = s.grade/100;
const Crr = Crrmap[s.surface] || 0.006;
const P_roll = totalMass * g * v_m_s * Crr;
const P_grav = totalMass * g * v_m_s * gradeDecimal;
const P_aero = 0.5 * rho * cda * Math.pow(v_m_s,3);
const P_wheel = Math.max(0, P_roll + P_grav + P_aero); // W at wheel
const P_meta = P_wheel / Math.max(0.15, efficiency); // metabolic W (efficiency min guard)
const speed_kmh = v_m_s * 3.6;
const time_h = (s.dist / Math.max(0.001, speed_kmh));
const kcal = P_meta * 3600 * time_h / 4184;
results.segments.push({
name: s.name, dist: s.dist, grade: s.grade, surface: s.surface,
chosenChain, chosenCass, gearRatio: gearRatio.toFixed(2), speed_kmh: speed_kmh.toFixed(1), time_h: time_h.toFixed(2), kcal: Math.round(kcal)
});
results.totalKm += s.dist;
results.totalTime_h += time_h;
results.totalCalories += kcal;
}
return results;
}
// ====== Render ======
function renderResults(r){
const el = document.getElementById('resultsBody');
let html = '';
html += `<p><strong>Całkowita odległość:</strong> ${r.totalKm.toFixed(1)} km</p>`;
html += `<p><strong>Szacowany czas ruchu (sumarycznie):</strong> ${r.totalTime_h.toFixed(1)} h</p>`;
const days = parseInt(document.getElementById('days').value) || 7;
const kmPerDay = (r.totalKm / days) || 0;
const timePerDay = (r.totalTime_h / days) || 0;
html += `<p><strong>Średnio na dzień:</strong> ${kmPerDay.toFixed(1)} km / ${timePerDay.toFixed(1)} h ruchu (nie licząc przerw)</p>`;
html += `<p><strong>Szacowane kalorie:</strong> ${Math.round(r.totalCalories)} kcal</p>`;
html += '<hr>';
html += '<h4 class="h6">Odcinki (szacunkowo)</h4>';
html += '<div class="table-responsive"><table class="table table-sm"><thead><tr><th>#</th><th>nazwa</th><th>km</th><th>nawierz.</th><th>śr. km/h</th><th>czas (h)</th><th>kcal</th></tr></thead><tbody>';
r.segments.forEach((s,i)=>{
html += `<tr><td>${i+1}</td><td>${escapeHtml(s.name)}</td><td>${s.dist}</td><td>${s.surface}</td><td>${s.speed_kmh}</td><td>${s.time_h}</td><td>${s.kcal}</td></tr>`;
});
html += '</tbody></table></div>';
html += `<hr><h4 class="h6">Proponowany harmonogram (orientacyjny)</h4>`;
html += `<p>Kiedy chcesz ukończyć w <strong>${days}</strong> dni, sugerowana średnia to <strong>${kmPerDay.toFixed(1)} km/dzień</strong>, czyli ~<strong>${timePerDay.toFixed(1)} h</strong> ruchu/dzień (bez przerw). Zalecane: 2–3 bloki jazdy dziennie, sen 8 h, przerwy 10–60 min na posiłki i serwis.</p>`;
el.innerHTML = html;
}
// ====== Events ======
document.getElementById('compute').addEventListener('click', ()=>{
const res = computeModel();
renderResults(res);
});
// Export CSV
function exportCsv(){
const res = computeModel();
let csv = 'nazwa,km,nawierzchnia,sred_kmh,czas_h,kcal\n';
res.segments.forEach(s=>{ csv += `${s.name.replace(/,/g,' ')} , ${s.dist} , ${s.surface} , ${s.speed_kmh} , ${s.time_h} , ${s.kcal}\n`; });
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'ultra_plan.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
document.getElementById('exportCsv').addEventListener('click', exportCsv);
// GPX splitting app
/* ---------- Helpery geodezyjne ---------- */
// Haversine distance in kilometers between two [lat, lon]
function haversineKm(a, b) {
const toRad = v => v * Math.PI / 180;
const R = 6371.0088; // mean Earth radius in km
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const lat1 = toRad(a[0]), lat2 = toRad(b[0]);
const s = Math.sin(dLat/2)**2 + Math.cos(lat1)*Math.cos(lat2)*Math.sin(dLon/2)**2;
return 2 * R * Math.asin(Math.sqrt(s));
}
// Linear interpolation on the geodesic segment approximated in lat/lon (sufficient for small segments)
function interpLatLon(a, b, t) {
// a and b are [lat, lon], t in [0,1]
return [ a[0] + (b[0]-a[0]) * t, a[1] + (b[1]-a[1]) * t ];
}
/* ---------- Parsowanie GPX -> listy punktów (lat,lon) ---------- */
function extractTrackCoordsFromGeoJSON(geojson) {
// Try to find LineString/MultiLineString features from tracks/routes
const coords = [];
function pushCoordArray(arr) {
for (const c of arr) {
// togeojson gives coords as [lon,lat] in GeoJSON; convert to [lat,lon]
coords.push([c[1], c[0]]);
}
}
if (geojson.type === "FeatureCollection") {
// Prefer "LineString" or "MultiLineString" in features
for (const f of geojson.features) {
if (!f.geometry) continue;
const type = f.geometry.type;
if (type === "LineString") pushCoordArray(f.geometry.coordinates);
else if (type === "MultiLineString") {
for (const part of f.geometry.coordinates) pushCoordArray(part);
} else if (type === "Point") {
// ignore single points for route track
}
}
} else if (geojson.type === "LineString") {
pushCoordArray(geojson.coordinates);
}
return coords;
}
/* ---------- Cięcie trasy na odcinki po zadanych dystansach ---------- */
/*
coords: array of [lat,lon] forming the polyline
cutDistances: array of cumulative distances (km) at which we want to cut
e.g. [10,20,30] means cut at 10km, 20km, 30km from start -> produce segments accordingly
returns array of segments; each segment is array of [lat,lon]
*/
function splitPolylineAtDistances(coords, cutDistances) {
if (coords.length < 2) return [coords.slice()];
// compute cumulative distances at each vertex
const cum = [0];
for (let i=1;i<coords.length;i++) {
cum.push(cum[i-1] + haversineKm(coords[i-1], coords[i]));
}
const total = cum[cum.length-1];
// clamp cutDistances
const cuts = cutDistances.map(d => Math.max(0, Math.min(d, total))).sort((a,b)=>a-b);
const segments = [];
let segStartIdx = 0;
let prevCutDist = 0;
for (const cutDist of cuts) {
// Build segment from prevCutDist to cutDist
const seg = [];
// find starting point: if prevCutDist == 0, start at coords[0]; else interpolate
if (prevCutDist === 0) {
seg.push(coords[0]);
} else {
// find interval where cum[i] < prevCutDist <= cum[i+1]
let i = 0;
while (i+1 < cum.length && cum[i+1] < prevCutDist) i++;
const d0 = cum[i], d1 = cum[i+1];
const t = (prevCutDist - d0) / (d1 - d0 || 1);
seg.push(interpLatLon(coords[i], coords[i+1], t));
}
// now add intermediate whole points until we reach the cut
let i = 0;
// find first index >= prevCutDist
while (i < cum.length && cum[i] <= prevCutDist) i++;
// push points while cum[i] < cutDist
while (i < cum.length && cum[i] < cutDist) {
seg.push(coords[i]);
i++;
}
// add the cut point (interpolated between i-1 and i)
if (i < cum.length) {
const d0 = cum[i-1], d1 = cum[i];
const t = (cutDist - d0) / (d1 - d0 || 1);
seg.push(interpLatLon(coords[i-1], coords[i], t));
} else {
// cut at the end
seg.push(coords[coords.length-1]);
}
segments.push(seg);
prevCutDist = cutDist;
}
// final segment from last cut to end
const lastSeg = [];
if (prevCutDist === 0) {
// we had no cuts
lastSeg.push(...coords);
} else {
// start point interpolated at prevCutDist
let i = 0;
while (i+1 < cum.length && cum[i+1] < prevCutDist) i++;
const d0 = cum[i], d1 = cum[i+1] || d0;
const t = (prevCutDist - d0) / (d1 - d0 || 1);
lastSeg.push(interpLatLon(coords[i], coords[i+1] || coords[i], t));
// add remaining points after prevCutDist
let j = i+1;
while (j < coords.length) {
lastSeg.push(coords[j]);
j++;
}
}
// guard: if lastSeg equals previous segment (degenerate), skip
if (lastSeg.length > 0) segments.push(lastSeg);
return segments;
}
/* ---------- Budowa prostego GPX do pobrania dla segmentu ---------- */
function buildGpxStringFromCoords(coords, name) {
function ptToXml(p) {
return `<trkpt lat="${p[0].toFixed(7)}" lon="${p[1].toFixed(7)}"></trkpt>`;
}
const pts = coords.map(ptToXml).join("\n ");
const now = new Date().toISOString();
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="GPX-splitter">
<metadata><name>${escapeXml(name)}</name><time>${now}</time></metadata>
<trk>
<name>${escapeXml(name)}</name>
<trkseg>
${pts}
</trkseg>
</trk>
</gpx>`;
}
function escapeXml(str) {
return String(str).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');
}
/* ---------- UI + map ---------- */
const map = L.map('map', { preferCanvas: true }).setView([52,19], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap'
}).addTo(map);
let globalLayers = L.layerGroup().addTo(map);
let rawCoords = [];
L.control.scale({
position: 'bottomleft', // domyślnie 'bottomleft', możesz dać 'bottomright' itd.
metric: true, // pokazuj metry/km
imperial: false, // ukryj mile
maxWidth: 200 // maksymalna długość skali w pikselach
}).addTo(map);
L.control.fullscreen({
position: 'topleft',
title: {
'false': 'Pełny ekran', // tekst, gdy NIE jest w trybie fullscreen
'true': 'Wyjdź z pełnego ekranu' // tekst, gdy JUŻ jest fullscreen
}
}).addTo(map);
document.getElementById('gpxFile').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const txt = await file.text();
parseGpxString(txt);
});
document.getElementById('loadUrlBtn').addEventListener('click', async () => {
const url = document.getElementById('gpxUrl').value.trim();
if (!url) { alert('Wpisz URL do pliku GPX.'); return; }
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Błąd HTTP: ' + res.status);
const txt = await res.text();
parseGpxString(txt);
} catch (err) {
alert('Nie udało się pobrać GPX: ' + err.message);
}
});
document.getElementById('processBtn').addEventListener('click', () => {
if (!rawCoords || rawCoords.length < 2) { alert('Wczytaj najpierw trasę GPX.'); return; }
const days = Math.max(1, parseInt(document.getElementById('daysInput').value || 7));
const kmPerDay = Number(document.getElementById('kmPerDayInput').value || 0);
const weightModel = document.getElementById('weightModel').value;
const intensity = Number(document.getElementById('intensity').value || 0.1)
processAndRenderSegments(rawCoords, days, kmPerDay, weightModel, intensity);
});
document.getElementById('fitMapBtn').addEventListener('click', () => {
if (!rawCoords || rawCoords.length === 0) return;
const latlngs = rawCoords.map(c => [c[0], c[1]]);
map.fitBounds(latlngs);
});
document.getElementById('weightModel').addEventListener('change', () => {
document.getElementById('intensity').disabled = document.getElementById('weightModel').value !== 'sport';
});
function parseGpxString(gpxText) {
try {
const parser = new DOMParser();
const xml = parser.parseFromString(gpxText, "application/xml");
// Convert to GeoJSON using togeojson
const geojson = toGeoJSON.gpx(xml);
const coords = extractTrackCoordsFromGeoJSON(geojson);
if (!coords || coords.length < 2) { alert('Nie znaleziono linii trasy w pliku GPX.'); return; }
rawCoords = coords;
// draw raw route
globalLayers.clearLayers();
const poly = L.polyline(coords.map(c=>[c[0],c[1]]), { weight: 4, color: '#333', opacity: 0.6 }).addTo(globalLayers);
map.fitBounds(poly.getBounds());
document.getElementById('summary').innerHTML = `<div class="alert alert-success">Trasa wczytana — ${coords.length} punktów.</div>`;
} catch (err) {
console.error(err);
alert('Błąd parsowania GPX: ' + err.message);
}
}
function processAndRenderSegments(coords, days, kmPerDay, weightModel = 'fatigue', intensity = 0.2) {
// ---------------------------
// helper: oblicz zmęczenie
// ---------------------------
function getFatigueFactor(days, totalKm) {
// Im więcej dni i im dłuższa trasa – tym większe zmęczenie
let base = 0.98 - Math.min(days / 50, 0.2);
base -= Math.min(totalKm / 10000, 0.1);
return Math.max(0.80, base); // nie pozwalamy spaść poniżej 0.8
}
// ---------------------------
// helper: model 1 - fatigue
// ---------------------------
function buildFatigueWeights(days, totalKm) {
const f = getFatigueFactor(days, totalKm);
const w = [];
let cur = 1;
for (let i = 0; i < days; i++) {
w.push(cur);
cur *= f;
}
return w;
}
// ---------------------------
// helper: model 2 - U shape
// ---------------------------
function buildUShapedWeights(days) {
const weights = [];
const mid = (days - 1) / 2;
for (let i = 0; i < days; i++) {
// dystans od środka (im dalej, tym ciężej => większa waga)
const dist = Math.abs(i - mid) / mid; // normalizacja 0–1
// kosinus daje minimum na środku i maksimum na początku i końcu
const w = 1 + 0.3 * (1 - Math.cos(dist * Math.PI));
weights.push(w);
}
return weights;
}
// ---------------------------
// helper: model 3 - sport
// ---------------------------
function buildSportWeights(days, intensity = 0.2) {
const w = [];
const start = 1 - intensity;
const end = 1 + intensity;
for (let i = 0; i < days; i++) {
const t = i / (days - 1);
w.push(start + (end - start) * t);
}
return w;
}
// compute cumulative distances
const dists = [0];
for (let i=1;i<coords.length;i++) dists.push(dists[i-1] + haversineKm(coords[i-1], coords[i]));
const totalKm = dists[dists.length-1];
// determine cut distances
let cutDistances = [];
if (kmPerDay > 0) {
// cut every kmPerDay until end
let cur = kmPerDay;
while (cur < totalKm - 1e-6) { // small epsilon
cutDistances.push(cur);
cur += kmPerDay;
}
} else {
// ---------------------------
// WAGOWY PODZIAŁ
// ---------------------------
let weights;
if (weightModel === 'fatigue') {
weights = buildFatigueWeights(days, totalKm);
} else if (weightModel === 'ushape') {
weights = buildUShapedWeights(days);
} else if (weightModel === 'sport') {
weights = buildSportWeights(days, intensity);
} else {
// domyślne – jak coś pójdzie nie tak
weights = new Array(days).fill(1);
}
const sumW = weights.reduce((a,b)=>a+b, 0);
// obliczamy dystans dzienny na podstawie wag
let cumulative = 0;
for (let i = 0; i < days - 1; i++) {
cumulative += (weights[i] / sumW) * totalKm;
cutDistances.push(cumulative);
}
}
const segments = splitPolylineAtDistances(coords, cutDistances);
// Clear and draw segments with colors
globalLayers.clearLayers();
const palette = [
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2',
'#7f7f7f','#bcbd22','#17becf'
];
const tableRows = [];
let segIdx = 0;
const segBounds = [];
for (const seg of segments) {
const color = palette[segIdx % palette.length];
const poly = L.polyline(seg.map(p=>[p[0],p[1]]), { color, weight: 5, opacity: 0.85 }).addTo(globalLayers);
segBounds.push(poly.getBounds());
// compute segment distance
let segKm = 0;
for (let i=1;i<seg.length;i++) segKm += haversineKm(seg[i-1], seg[i]);
const start = seg[0], end = seg[seg.length-1];
const name = `Dzień ${segIdx+1}`;
// create download buttons
const gpxStr = buildGpxStringFromCoords(seg, name);
const gpxBlob = new Blob([gpxStr], {type: 'application/gpx+xml'});
const gpxUrl = URL.createObjectURL(gpxBlob);
const geojson = {
"type":"FeatureCollection",
"features":[{"type":"Feature","properties":{"name":name},"geometry":{"type":"LineString","coordinates": seg.map(p=>[p[1],p[0]])}}]
};
const geojsonStr = JSON.stringify(geojson, null, 2);
const geoBlob = new Blob([geojsonStr], {type: 'application/json'});
const geoUrl = URL.createObjectURL(geoBlob);
tableRows.push({
index: segIdx+1, km: segKm, start, end, color, gpxUrl, geoUrl, name
});
segIdx++;
}
// Display summary
const summaryHtml = `
<div class="card mb-3">
<div class="card-body">
<strong>Całkowita długość:</strong> ${totalKm.toFixed(2)} km.
<br>
<strong>Liczba segmentów:</strong> ${segments.length}.
<br>
${kmPerDay>0 ? `<strong>Km/dzień:</strong> ${kmPerDay} (ostatni dzień krótszy).` : `<strong>Dni:</strong> ${days}.` }
</div>
</div>`;
document.getElementById('summary').innerHTML = summaryHtml;
// Build table
let tableHtml = `<table class="table table-sm table-striped"><thead><tr>
<th>Dzień</th><th>Dystans (km)</th><th>Start (lat,lon)</th><th>Koniec (lat,lon)</th><th>Pliki</th>
</tr></thead><tbody>`;
for (const r of tableRows) {
tableHtml += `<tr>
<td><span class="badge" style="background:${r.color};width:18px;height:18px;display:inline-block;border-radius:4px;margin-right:8px;"></span>${r.index} — ${r.name}</td>
<td>${r.km.toFixed(2)}</td>
<td class="small-mono">${r.start[0].toFixed(6)}, ${r.start[1].toFixed(6)}</td>
<td class="small-mono">${r.end[0].toFixed(6)}, ${r.end[1].toFixed(6)}</td>
<td>
<a class="btn btn-sm btn-outline-primary me-1" href="${r.gpxUrl}" download="${r.name.replaceAll(' ','_')}.gpx">GPX</a>
<a class="btn btn-sm btn-outline-secondary" href="${r.geoUrl}" download="${r.name.replaceAll(' ','_')}.geojson">GeoJSON</a>
</td>
</tr>`;
}
tableHtml += `</tbody></table>`;
document.getElementById('segmentsTable2').innerHTML = tableHtml;
// Fit map to whole route
const allBounds = segments.reduce((acc, seg, idx) => {
const b = L.latLngBounds(seg.map(p=>[p[0],p[1]]));
if (!acc) return b;
return acc.extend(b);
}, null);
if (allBounds) map.fitBounds(allBounds, { padding: [20,20] });
}
</script>
Kod po stronie serwera
Brak kodu serwera
Ta aplikacja działa wyłącznie w przeglądarce i nie korzysta z kodu po stronie serwera.