Ultra Route Planner
Chcesz przygotować się do Wisła 1200 lub innego 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ę.
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
Kod po stronie przeglądarki
<style>
[data-bs-theme=light] .btn-outline-secondary {
--bs-btn-color: #666f76;
--bs-btn-border-color: #666f76 ;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #666f76 ;
--bs-btn-hover-border-color: #666f76;
--bs-btn-focus-shadow-rgb: 108, 117, 125;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #666f76 ;
--bs-btn-active-border-color: #666f76;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #666f76;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #666f76;
--bs-gradient: none;
}
[data-bs-theme=golden] .btn-outline-primary {
--bs-btn-color: #0c68f0;
--bs-btn-border-color: #0c68f0;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #0c68f0;
--bs-btn-hover-border-color: #0c68f0;
--bs-btn-focus-shadow-rgb: 13, 110, 253;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #0c68f0;
--bs-btn-active-border-color: #0c68f0;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #0c68f0;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #0c68f0;
--bs-gradient: none;
}
[data-bs-theme=golden] .btn-outline-secondary {
--bs-btn-color: #606970;
--bs-btn-border-color: #606970 ;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #606970 ;
--bs-btn-hover-border-color: #606970;
--bs-btn-focus-shadow-rgb: 108, 117, 125;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #606970 ;
--bs-btn-active-border-color: #606970;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #606970;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #606970;
--bs-gradient: none;
}
[data-bs-theme=twilight] .btn-outline-primary, [data-bs-theme=dark] .btn-outline-primary {
--bs-btn-color: #0d90ff;
--bs-btn-border-color: #0d90ff;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #0d90ff;
--bs-btn-hover-border-color: #0d90ff;
--bs-btn-focus-shadow-rgb: 13, 110, 253;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #0d90ff;
--bs-btn-active-border-color: #0d90ff;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #0d90ff;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #0d90ff;
--bs-gradient: none;
}
[data-bs-theme=twilight] .btn-outline-secondary {
--bs-btn-color: #8d9aa4;
--bs-btn-border-color: #8d9aa4;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #8d9aa4;
--bs-btn-hover-border-color: #8d9aa4;
--bs-btn-focus-shadow-rgb: 108, 117, 125;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #8d9aa4 ;
--bs-btn-active-border-color: #8d9aa4;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #8d9aa4;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #8d9aa4;
--bs-gradient: none;
}
[data-bs-theme=dark] .btn-outline-secondary {
--bs-btn-color: #87939d;
--bs-btn-border-color: #87939d;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #87939d;
--bs-btn-hover-border-color: #87939d;
--bs-btn-focus-shadow-rgb: 108, 117, 125;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #87939d ;
--bs-btn-active-border-color: #87939d;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #87939d;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #87939d;
--bs-gradient: none;
}
</style>
<div id="app">
<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>
</div>
<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 7-dniowy 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);
</script>
Kod po stronie serwera
Informacja
Aplikacja nie korzysta z kodu po stronie serwera.