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.
Licencja
## BSD-3-Clause License Agreement
BSD-3-Clause
Сopyright (c) 2026 Dariusz Rorat
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.