Przejdź do głównej treści

Ultra Route Planner

Grafika SVG Rower

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ąć?

  1. Wypełnij dane roweru i zawodnika.
  2. Dodaj odcinki trasy lub zostaw pustą tabelę, a aplikacja sama rozdzieli dystans na dni.
  3. Kliknij Oblicz plan i sprawdź wyniki!
  4. (opcjonalnie) Eksportuj CSV i zapisz dane.
Uwaga

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

  1. Wczytaj plik .gpx:
    • kliknij „Wczytaj plik GPX” i wybierz plik z dysku, lub
    • wklej URL do pliku GPX i kliknij „Wczytaj URL”.
  2. 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.
  3. 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.
  4. W przypadku modelu sportowego wybierz intensywność
  5. 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.
Uwaga
  • 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

Czynniki modelu wpływające na prędkość i opory

Odcinki trasy

#Nazwakmpochylenie(%)nawierzchniauwagiakcje

Wyniki

Brak obliczeń.

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.

Podziel trasę
Dopasuj mapę
Wczytaj GPX, kliknij „Podziel trasę”. Możesz zmienić liczbę dni lub podać km/dzień.
Uwaga: cięcie trasy interpoluje punkty liniowo między kolejnymi punktami trasy (dokładność wystarczająca dla planowania wędrówki).

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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[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('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&apos;');
}

/* ---------- 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: '&copy; 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.

25 września 2025 3

Kategorie

Technologie

Dziękujemy!
()

Informacja o cookies

Moja strona internetowa wykorzystuje wyłącznie niezbędne pliki cookies, które są wymagane do jej prawidłowego działania. Nie używam ciasteczek w celach marketingowych ani analitycznych. Korzystając z mojej strony, wyrażasz zgodę na stosowanie tych plików. Możesz dowiedzieć się więcej w mojej polityce prywatności.