Przejdź do głównej treści

GPX OpenStreetMap Viewer

Grafika SVG Lokalizacja

Prosta, lekka aplikacja webowa pozwalająca wczytywać i wyświetlać trasy GPX bez instalowania czegokolwiek.
Działa w całości w przeglądarce – wystarczy wczytać plik GPX i już możesz przeglądać swoje trasy!

Co potrafi

  • Wczytuje pliki GPX z dysku lub z adresu URL
  • Wyświetla trasę na mapie OpenStreetMap (Leaflet)
  • Automatycznie dopasowuje widok mapy do trasy
  • Oblicza długość trasy (w kilometrach)
  • Obsługuje wiele tras naraz — każda w innym kolorze
  • Możliwość ukrycia, pokazania lub usunięcia trasy z mapy

Jak używać

  1. Wczytaj plik .gpx z dysku lub wklej link do pliku z sieci.
  2. Gotowe! Trasa pojawi się na mapie — możesz:
    • przybliżać widok,
    • wyświetlać wiele tras naraz,
    • usuwać je z listy jednym kliknięciem.

Technologie

  • HTML5 + Bootstrap 5.3 – nowoczesny, responsywny interfejs
  • Leaflet – mapa bazująca na danych OpenStreetMap
  • toGeoJSON – konwersja plików GPX (XML) na GeoJSON

Dlaczego warto

  • Nie wymaga logowania ani serwera
  • Idealne narzędzie dla rowerzystów, biegaczy i organizatorów rajdów
  • Możesz analizować swoje trasy przeglądając mapę

Sprawdź, jak wygląda Twoja trasa z perspektywy mapy!
Wczytaj plik GPX i zobacz, dokąd prowadzi Twój szlak.

Wczytaj plik(i) GPX

Możesz dodać kilka plików naraz.


lub URL do pliku GPX

Opcje trasy

Lista tras

Brak wczytanych tras
Uwaga: wczytywanie GPX z zewnętrznych serwerów może być blokowane przez CORS.

Profil wysokości

Podsumowanie trasy – objaśnienie parametrów

Poniższe wartości zostały obliczone na podstawie danych z pliku GPX (ślad GPS). Pomagają zrozumieć charakter trasy – czy jest płaska, górzysta czy opadająca.

  • Długość trasy [km] – całkowita długość śladu, obliczona metodą Haversine (rzeczywista odległość po powierzchni Ziemi).
  • Przewyższenie pod górę [m] (Total Ascent) – suma wszystkich odcinków, na których wysokość rośnie.
  • Przewyższenie w dół [m] (Total Descent) – suma odcinków, na których wysokość spada.
  • Średnie nachylenie [%] – różnica między całkowitym nachyleniem w górę i w dół względem długości trasy:
    (Ascent − Descent) / Distance × 100.
    Wartości dodatnie oznaczają trasy narastające (w górę), ujemne – opadające.
  • Min / Max wysokość [m] – najniższy i najwyższy punkt trasy.
Wskazówka: dane są szacowane na podstawie punktów GPS. Wysokości są automatycznie wygładzane, aby ograniczyć wpływ błędów pomiarowych.
Nazwa trasy Długość Pod górę W dół Śr. nachylenie Min Max

Kod po stronie przeglądarki

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="" crossorigin=""/>
<link rel="stylesheet" href="https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/leaflet.fullscreen.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-control-geocoder/dist/Control.Geocoder.css" />
<link type="text/css" href="http://www.dariuszrorat.ugu.pl/assets/css/bootstrap/wcag-outline.min.css" rel="stylesheet"> 
<style>
#map { height: 70vh; min-height: 400px; }
.track-item { cursor: pointer; }

/* Naprawia widoczność tekstu w polu wyszukiwania */
.leaflet-control-geocoder-form input {
  color: black !important;        /* kolor tekstu wpisywanego */
  background-color: white !important; /* kolor tła pola */
  font-size: 14px;
  padding: 4px 8px;
}

/* Opcjonalnie: popraw wygląd wyników */
.leaflet-control-geocoder-alternatives li {
  background-color: white;
  color: black;
  padding: 4px 6px;
  border-bottom: 1px solid #ccc;
}
.leaflet-control-geocoder-alternatives li:hover {
  background-color: #eee;
}
</style>

<div id="app">
    <div class="row g-3 mb-3">
      <div class="col-md-5">
        <div class="card">
          <div class="card-body">
            <h3 class="h5 card-title">Wczytaj plik(i) GPX</h3>
            <p class="card-text small text-muted">Możesz dodać kilka plików naraz.</p>
            <input id="gpxFiles" class="form-control mb-2" type="file" accept=".gpx,application/gpx+xml" multiple aria-label="Wybierz pliki">
            <hr>
            <h4 class="h6">lub URL do pliku GPX</h4>
            <div class="input-group mb-2">
              <input id="gpxUrl" type="url" class="form-control" placeholder="https://example.com/route.gpx" aria-label="Wczytaj adres URL">
              <button id="loadUrlBtn" class="btn btn-primary">Wczytaj</button>
            </div>
            <div class="form-check form-switch mb-2">
              <input class="form-check-input" type="checkbox" id="autoFit" checked>
              <label class="form-check-label" for="autoFit">Automatycznie dopasuj mapę po załadowaniu</label>
            </div>
            <div id="alerts"></div>
          </div>
        </div>

        <div class="card mt-3">
          <div class="card-body">
            <h3 class="h5 card-title">Opcje trasy</h3>
            <div class="mb-2">
              <label for="palette" class="form-label">Paleta kolorów</label>
              <select id="palette" class="form-select">
                <option value="0">Bootstrap colors</option>
                <option value="1">Vibrant map</option>
                <option value="2">Outdoor natural</option>
                <option value="3">Dark friendly</option>
              </select>
            </div>
          </div>
        </div>

        <div class="card mt-3">
          <div class="card-body">
            <h3 class="h5 card-title">Lista tras</h3>
            <div id="tracksList" class="list-group">
              <div class="list-group-item small text-muted">Brak wczytanych tras</div>
            </div>
            <div class="mt-2">
              <button id="clearAll" class="btn btn-outline-danger btn-sm">Usuń wszystkie</button>
            </div>
          </div>
        </div>
      </div>

      <div class="col-md-7">
        <div id="map" class="border rounded"></div>
        <div class="mt-2 small text-muted">Uwaga: wczytywanie GPX z zewnętrznych serwerów może być blokowane przez CORS.</div>
        <!-- --- PROFIL WYSOKOŚCI --- -->
        <div class="card mt-3">
          <div class="card-body">
            <h3 class="h5 card-title">Profil wysokości</h3>
            <canvas id="elevationChart" height="80"></canvas>
          </div>
        </div>
        <!-- --- KONIEC --- -->
      </div>

    </div>

    <div class="card mt-4 shadow-sm border-0">
      <div class="card-body">
        <h3 class="h5 card-title mb-3">
          <i class="bi bi-graph-up-arrow me-2 text-primary"></i>
          Podsumowanie trasy – objaśnienie parametrów
        </h3>

        <p class="text-muted small">
          Poniższe wartości zostały obliczone na podstawie danych z pliku <strong>GPX</strong> (ślad GPS).
          Pomagają zrozumieć charakter trasy – czy jest płaska, górzysta czy opadająca.
        </p>

        <ul class="list-group list-group-flush mb-3">
          <li class="list-group-item">
            <strong>Długość trasy [km]</strong> – całkowita długość śladu, obliczona metodą Haversine
            (rzeczywista odległość po powierzchni Ziemi).
          </li>

          <li class="list-group-item">
            <strong>Przewyższenie pod górę [m] (Total Ascent)</strong> – suma wszystkich odcinków, na których wysokość rośnie.
          </li>

          <li class="list-group-item">
            <strong>Przewyższenie w dół [m] (Total Descent)</strong> – suma odcinków, na których wysokość spada.
          </li>

          <li class="list-group-item">
            <strong>Średnie nachylenie [%]</strong> – różnica między całkowitym nachyleniem w górę i w dół
            względem długości trasy:
            <br>
            <code>(Ascent − Descent) / Distance × 100</code>.
            <br>
            Wartości dodatnie oznaczają trasy narastające (w górę),
            ujemne – opadające.
          </li>

          <li class="list-group-item">
            <strong>Min / Max wysokość [m]</strong> – najniższy i najwyższy punkt trasy.
          </li>
        </ul>

        <div class="callout callout-info mb-0">
          <i class="bi bi-lightbulb"></i>
          <strong>Wskazówka:</strong> dane są szacowane na podstawie punktów GPS.
          Wysokości są automatycznie <em>wygładzane</em>, aby ograniczyć wpływ błędów pomiarowych.
        </div>
      </div>
    </div>

    <div class="mt-3">
      <div class="table-responsive">
        <table class="table" id="summaryTable">
          <thead>
            <tr>
              <th>Nazwa trasy</th>
              <th>Długość</th>
              <th>Pod górę</th>
              <th>W dół</th>
              <th>Śr. nachylenie</th>
              <th>Min</th>
              <th>Max</th>
            </tr>
          </thead>
          <tbody></tbody>
        </table>
        </div>
    </div>

</div>
<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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/togeojson/0.16.0/togeojson.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/leaflet-control-geocoder/dist/Control.Geocoder.js"></script>
<script>
  const osmLight = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    maxZoom: 19,
    attribution: '© OpenStreetMap'
  });
  const osmDark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    maxZoom: 19,
    attribution: '© OpenStreetMap, © CARTO'
  });
  const esriSat = L.tileLayer(
    'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
      attribution: '&copy; Esri, Maxar, Earthstar Geographics'
  });
  const esriTopo = L.tileLayer(
    'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', {
      attribution: '&copy; Esri'
  });
  const openTopo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
    maxZoom: 17,
    attribution: '&copy; OpenTopoMap contributors'
  });

  // Nakładki tematyczne
  const railways = L.tileLayer('https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', {
    attribution: '&copy; OpenRailwayMap contributors'
  });
  const seaports = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
    attribution: '&copy; OpenSeaMap contributors'
  });
  const hiking = L.tileLayer('https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png', {
      attribution: '&copy; waymarkedtrails.org'
  });

  const defaultMap = osmLight;

  const map = L.map('map', {
    center: [52.1, 19.4],
    zoom: 6,
    layers: [defaultMap]
  });

  defaultMap.addTo(map);

  const baseLayers = {
    "Jasna mapa": osmLight,
    "Ciemna mapa": osmDark,
    "Satelita (Esri)": esriSat,
    "Topograficzna (Esri)": esriTopo,
    "Teren (OpenTopoMap)": openTopo
  };
  const overlays = {
    "Linie kolejowe": railways,
    "Dane morskie i porty": seaports,
    "Szlaki turystyczne": hiking
  };

  L.control.layers(baseLayers, overlays).addTo(map);
  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);

  // Dodaj wyszukiwarkę miast / adresów
  L.Control.geocoder({
    position: 'topleft',
    defaultMarkGeocode: true, // automatycznie dodaje marker i zoom
    placeholder: 'Szukaj miasta...',
    errorMessage: 'Nie znaleziono'
  }).addTo(map);

  // Kontenery
  const tracks = []; // { id, name, layer, color, lengthKm, bounds }
  const colorSchemes = [
      // Bootstrap colors
      [
          '#0d6efd', // primary
          '#6c757d', // secondary
          '#198754', // success
          '#dc3545', // danger
          '#ffc107', // warning
          '#0dcaf0', // info
          '#6610f2', // purple (Bootstrap accent)
          '#fd7e14', // orange
          '#20c997', // teal
          '#212529' // dark
      ],
      // Vibrant map
      [
          '#e41a1c', // czerwony
          '#377eb8', // niebieski
          '#4daf4a', // zielony
          '#984ea3', // fiolet
          '#ff7f00', // pomarańczowy
          '#ffff33', // żółty
          '#a65628', // brąz
          '#f781bf', // różowy
          '#999999', // szary
          '#66c2a5'  // morski
      ],
      // Outdoor natural
      [
          '#2a9d8f', // morska zieleń
          '#e9c46a', // piaskowy żółty
          '#f4a261', // ciepły pomarańczowy
          '#e76f51', // ceglany czerwony
          '#264653', // głęboki morski
          '#90be6d', // zielony trawiasty
          '#577590', // chłodny niebieski
          '#f3722c', // pomarańczowy
          '#43aa8b', // morski turkus
          '#277da1'  // oceaniczny niebieski
      ],
      // Dark friendly
      [
          '#ff6b6b', // koralowy
          '#feca57', // bursztynowy
          '#1dd1a1', // miętowy
          '#54a0ff', // błękit
          '#5f27cd', // fiolet
          '#ff9ff3', // jasnoróżowy
          '#48dbfb', // cyjan
          '#10ac84', // szmaragd
          '#ff9f43', // pomarańczowy
          '#c8d6e5'  // jasnoszary
      ]
  ];

  // Elementy UI
  const gpxFilesInput = document.getElementById('gpxFiles');
  const tracksListEl = document.getElementById('tracksList');
  const alertsEl = document.getElementById('alerts');
  const loadUrlBtn = document.getElementById('loadUrlBtn');
  const gpxUrlInput = document.getElementById('gpxUrl');
  const autoFitCheckbox = document.getElementById('autoFit');
  const clearAllBtn = document.getElementById('clearAll');

  // --- Chart.js setup ---
  let chart = new Chart(document.getElementById('elevationChart'), {
      type: 'line',
      data: {
          labels: [],
          datasets: [{
              label: 'Wysokość (m)',
              data: [],
              borderColor: '#007bff',
              fill: true,
              tension: 0.1,
              pointRadius: 0
          }]
      },
      options: {
          scales: {
              x: {
                  title: {
                      display: true,
                      text: 'Dystans (km)'
                  }
              },
              y: {
                  title: {
                      display: true,
                      text: 'Wysokość (m)'
                  }
              }
          },
          plugins: {
              legend: {
                  display: false
              }
          },
          responsive: true,
          maintainAspectRatio: true
      }
  });

  // ---------- Pomocnicze: komunikaty ----------
  function showAlert(msg, type = 'info', timeout = 5000) {
      const id = 'a' + Date.now();
      const wrapper = document.createElement('div');
      wrapper.innerHTML = `
      <div id="${id}" class="alert alert-${type} alert-dismissible fade show" role="alert">
        ${escapeHtml(msg)}
        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Zamknij"></button>
      </div>`;
      alertsEl.appendChild(wrapper);
      if (timeout > 0) setTimeout(() => {
          const el = document.getElementById(id);
          if (el) el.remove();
      }, timeout);
  }

  function escapeHtml(unsafe) {
      return unsafe
          .replaceAll('&', '&amp;')
          .replaceAll('<', '&lt;')
          .replaceAll('>', '&gt;')
          .replaceAll('"', '&quot;')
          .replaceAll("'", '&#039;');
  }

  // ---------- Parsowanie GPX (z File lub z tekstu) ----------
  async function parseGpxText(gpxText) {
      try {
          const parser = new DOMParser();
          const xml = parser.parseFromString(gpxText, "application/xml");
          // check for parsererror
          if (xml.querySelector('parsererror')) {
              throw new Error('Błąd parsowania GPX (XML) – prawdopodobnie uszkodzony plik.');
          }
          // konwersja do GeoJSON (togeojson)
          const geojson = toGeoJSON.gpx(xml);
          return {
              xml,
              geojson
          };
      } catch (err) {
          throw err;
      }
  }

  // --- Odczyt profilu wysokości z <trkpt> ---
  function extractElevationProfile(xml) {
      const pts = Array.from(xml.querySelectorAll('trkpt'));
      const data = [];
      let dist = 0;
      let prev = null;
      for (const p of pts) {
          const lat = parseFloat(p.getAttribute('lat'));
          const lon = parseFloat(p.getAttribute('lon'));
          const ele = parseFloat(p.querySelector('ele')?.textContent || 0);
          const ll = L.latLng(lat, lon);
          if (prev) dist += prev.distanceTo(ll) / 1000; // km
          data.push({
              dist,
              ele
          });
          prev = ll;
      }
      return data;
  }

  function updateChart(profile, color = "#0000ff") {
      if (!profile || profile.length === 0) {
          chart.data.labels = [];
          chart.data.datasets[0].data = [];
          chart.update();
          return;
      }

      chart.data.labels = profile.map(p => p.dist.toFixed(2));
      chart.data.datasets[0].data = profile.map(p => p.ele);
      chart.data.datasets[0].borderColor = color;
      chart.update();
  }

  // ========================
  // 🔹 Pomocnicza funkcja dystansu
  // ========================
  function haversineDistanceKm(lat1, lon1, lat2, lon2) {
      const R = 6371;
      const toRad = Math.PI / 180;
      const dLat = (lat2 - lat1) * toRad;
      const dLon = (lon2 - lon1) * toRad;
      const a = Math.sin(dLat / 2) ** 2 +
          Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) *
          Math.sin(dLon / 2) ** 2;
      return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }

  // ========================
  // 🔹 Spłaszczenie współrzędnych z GeoJSON
  // ========================
  function flattenCoords(coords) {
      const out = [];
      (function rec(c) {
          if (!c) return;
          if (typeof c[0] === 'number') {
              out.push(c);
          } else {
              for (const e of c) rec(e);
          }
      })(coords);
      return out;
  }

  // ========================
  // 🔹 Wygładzanie wysokości (średnia ruchoma)
  // ========================
  function smoothElevations(elevs, window = 5) {
      if (window <= 1) return elevs.slice();
      const n = elevs.length;
      const out = new Array(n);
      const half = Math.floor(window / 2);
      for (let i = 0; i < n; i++) {
          let sum = 0,
              cnt = 0;
          for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
              if (!isNaN(elevs[j])) {
                  sum += elevs[j];
                  cnt++;
              }
          }
          out[i] = cnt ? sum / cnt : NaN;
      }
      return out;
  }

  // ========================
  // 📊 Główna funkcja analizująca trasę GPX/GeoJSON
  // ========================
  function analyzeGeoJSON(geojson, opts = {}) {
      const smoothingWindow = opts.smoothingWindow ?? 7;
      const minElevChange = opts.minElevationChange ?? 3;

      // Zbieranie współrzędnych
      let allCoords = [];
      for (const feat of (geojson.features || [])) {
          if (!feat.geometry) continue;
          const type = feat.geometry.type;
          if (type === "LineString" || type === "MultiLineString") {
              allCoords = allCoords.concat(flattenCoords(feat.geometry.coordinates));
          } else if (type === "GeometryCollection") {
              for (const g of feat.geometry.geometries || []) {
                  if (g.type === "LineString" || g.type === "MultiLineString") {
                      allCoords = allCoords.concat(flattenCoords(g.coordinates));
                  }
              }
          }
      }

      if (allCoords.length < 2) {
          return {
              distanceKm: 0,
              totalAscentM: 0,
              totalDescentM: 0,
              avgSlopePct: 0,
              minEle: 0,
              maxEle: 0,
              netElevation: 0
          };
      }

      // Zamiana punktów na obiekty z wysokością
      const pts = allCoords.map(c => ({
          lon: c[0],
          lat: c[1],
          ele: c[2]
      }));

      const elevsRaw = pts.map(p => isNaN(p.ele) ? NaN : p.ele);
      const anyEle = elevsRaw.some(v => !isNaN(v));

      let totalDistance = 0;
      let ascent = 0;
      let descent = 0;
      let minEle = Infinity;
      let maxEle = -Infinity;

      // Jeśli brak wysokości – licz tylko dystans
      if (!anyEle) {
          for (let i = 1; i < pts.length; i++) {
              totalDistance += haversineDistanceKm(
                  pts[i - 1].lat, pts[i - 1].lon,
                  pts[i].lat, pts[i].lon
              );
          }
          return {
              distanceKm: totalDistance.toFixed(2),
              totalAscentM: 0,
              totalDescentM: 0,
              avgSlopePct: 0.00,
              minEle: 0,
              maxEle: 0,
              netElevation: 0
          };
      }

      // Wygładzenie wysokości i obliczenia
      const elevs = smoothElevations(elevsRaw, smoothingWindow);
      let prevLat = pts[0].lat,
          prevLon = pts[0].lon,
          prevEle = elevs[0];

      for (let i = 1; i < pts.length; i++) {
          const cur = pts[i];
          const d = haversineDistanceKm(prevLat, prevLon, cur.lat, cur.lon);
          totalDistance += d;

          const curEle = elevs[i];
          if (!isNaN(curEle)) {
              if (curEle < minEle) minEle = curEle;
              if (curEle > maxEle) maxEle = curEle;
          }

          if (!isNaN(curEle) && !isNaN(prevEle)) {
              const delta = curEle - prevEle;
              if (Math.abs(delta) >= minElevChange) {
                  if (delta > 0) ascent += delta;
                  else descent += -delta;
              }
          }

          prevLat = cur.lat;
          prevLon = cur.lon;
          prevEle = curEle;
      }

      // Średnie nachylenie (dla odcinków w górę)
      const avgSlopePct = totalDistance > 0 ?
          ((ascent-descent) / (totalDistance * 1000)) * 100 :
          0;

      // Przewyższenie netto (różnica między najwyższym i najniższym punktem)
      const netElevation = maxEle - minEle;

      return {
          distanceKm: totalDistance.toFixed(2),
          totalAscentM: Math.round(ascent),
          totalDescentM: Math.round(descent),
          avgSlopePct: avgSlopePct.toFixed(2),
          minEle: Math.round(minEle),
          maxEle: Math.round(maxEle),
          netElevation: Math.round(netElevation)
      };
  }


  // ========================
  // 📝 Dodawanie wyników do tabeli
  // ========================
  function addSummaryRow(geojson) {
      const s = analyzeGeoJSON(geojson, {
          smoothingWindow: 0,
          minElevationChange: 0
      });
      const name = geojson.features[0].properties.name || "Nieznana trasa";
      const tbody = document.querySelector("#summaryTable tbody");
      const row = document.createElement("tr");
      row.innerHTML = `
    <td>${name}</td>
    <td>${s.distanceKm} km</td>
    <td>${s.totalAscentM} m</td>
    <td>${s.totalDescentM} m</td>
    <td>${s.avgSlopePct} %</td>
    <td>${s.minEle} m</td>
    <td>${s.maxEle} m</td>
  `;
      tbody.appendChild(row);
  }

  // ---------- Dodaj trasę na mapę ----------
  function addTrack(geojson, xml, metaName) {
      // Wybierz kolor cyklicznie
      const palette = document.getElementById('palette').value;
      const colors = colorSchemes[palette];
      const color = colors[tracks.length % colors.length];

      // Utwórz warstwę Leaflet z GeoJSON
      const trackLayer = L.geoJSON(geojson, {
          style: function(feature) {
              return {
                  color: color,
                  weight: 4,
                  opacity: 0.85
              };
          },
          pointToLayer: function(feature, latlng) {
              // jeżeli punkt (np. waypoint) - zrób marker
              return L.circleMarker(latlng, {
                  radius: 4,
                  weight: 1
              });
          },
          onEachFeature: function(feature, layer) {
              let html = '';
              if (feature.properties && Object.keys(feature.properties).length) {
                  html += '<div>';
                  for (const k in feature.properties) {
                      // ograniczanie długości
                      const v = String(feature.properties[k]).slice(0, 200);
                      html += `<strong>${escapeHtml(k)}:</strong> ${escapeHtml(v)}<br/>`;
                  }
                  html += '</div>';
              }
              if (html) layer.bindPopup(html);
          }
      }).addTo(map);

      // compute bounds and length
      const bounds = trackLayer.getBounds();
      const lengthKm = computeLayerLengthKm(trackLayer);

      const id = 'track-' + Date.now() + '-' + Math.floor(Math.random() * 1000);
      const name = metaName || ('Trasa ' + (tracks.length + 1));
      const profile = extractElevationProfile(xml);

      addSummaryRow(geojson);

      tracks.push({
          id,
          name,
          layer: trackLayer,
          color,
          lengthKm,
          bounds,
          profile
      });
      refreshTracksList();
      updateChart(profile, color);

      if (autoFitCheckbox.checked) {
          if (bounds.isValid()) map.fitBounds(bounds.pad(0.1));
      }
      showAlert(`Dodano trasę "${name}" — długość: ${lengthKm.toFixed(2)} km`, 'success', 4000);
  }

  // ---------- Oblicz długość trasy w km (sumujemy segmenty liniowe) ----------
  function computeLayerLengthKm(layer) {
      let totalMeters = 0;
      layer.eachLayer(function(sub) {
          if (sub instanceof L.Polyline) {
              const latlngs = sub.getLatLngs();
              // Jeśli latlngs jest zagnieżdżone (multilinestring), obsłuż rekurencyjnie
              const flat = flattenLatLngs(latlngs);
              for (let i = 1; i < flat.length; i++) {
                  totalMeters += flat[i - 1].distanceTo(flat[i]); // Leaflet distanceTo (meters)
              }
          }
      });
      return totalMeters / 1000;
  }

  function flattenLatLngs(arr) {
      // flattens nested arrays of latlngs
      const out = [];
      (function f(a) {
          if (!a) return;
          if (Array.isArray(a)) {
              for (const e of a) f(e);
          } else {
              out.push(a);
          }
      })(arr);
      return out;
  }

  // ---------- UI: odśwież lista tras ----------
  function refreshTracksList() {
      tracksListEl.innerHTML = '';
      if (tracks.length === 0) {
          tracksListEl.innerHTML = '<div class="list-group-item small text-muted">Brak wczytanych tras</div>';
          return;
      }
      tracks.forEach((t, idx) => {
          const item = document.createElement('div');
          item.className = 'list-group-item d-flex justify-content-between align-items-start track-item';
          item.innerHTML = `
        <div>
          <div class="fw-semibold">${escapeHtml(t.name)}</div>
          <div class="small text-muted">Długość: ${t.lengthKm.toFixed(2)} km</div>
        </div>
        <div class="text-end">
          <div class="mb-1">
            <button class="btn btn-sm btn-outline-secondary btn-zoom" data-id="${t.id}" title="Dopasuj widok"><i class="bi bi-search"></i></button>
            <button class="btn btn-sm btn-outline-secondary btn-toggle" data-id="${t.id}" title="Pokaż/ukryj"><i class="bi bi-eye"></i></button>
          </div>
          <div style="width:18px;height:8px;border-radius:2px;background:${t.color};margin:0 auto;"></div>
        </div>
      `;
          item.onclick = () => {
              map.fitBounds(t.bounds.pad(0.1));
              updateChart(t.profile, t.color);
          };
          tracksListEl.appendChild(item);
      });

      // Podłącz zdarzenia
      tracksListEl.querySelectorAll('.btn-zoom').forEach(btn => {
          btn.onclick = () => {
              const id = btn.dataset.id;
              const t = tracks.find(x => x.id === id);
              if (t && t.bounds.isValid()) map.fitBounds(t.bounds.pad(0.1));
          };
      });
      tracksListEl.querySelectorAll('.btn-toggle').forEach(btn => {
          btn.onclick = () => {
              const id = btn.dataset.id;
              const t = tracks.find(x => x.id === id);
              if (!t) return;
              if (map.hasLayer(t.layer)) {
                  map.removeLayer(t.layer);
                  btn.innerHTML = '<i class="bi bi-ban"></i>';
              } else {
                  map.addLayer(t.layer);
                  btn.innerHTML = '<i class="bi bi-eye"></i>';
              }
          };
      });
  }

  // ---------- Wczytanie pliku lokalnego (File API) ----------
  gpxFilesInput.addEventListener('change', async (ev) => {
      const files = Array.from(ev.target.files || []);
      if (files.length === 0) return;
      for (const f of files) {
          try {
              const text = await f.text();
              const {
                  geojson
              } = await parseGpxText(text);
              // Spróbuj pobrać nazwę z pliku GPX / XML <name> w <trk> albo <metadata><name>
              const parser = new DOMParser();
              const xml = parser.parseFromString(text, "application/xml");
              let name = f.name;
              const metaName = xml.querySelector('metadata > name')?.textContent ||
                  xml.querySelector('trk > name')?.textContent ||
                  xml.querySelector('rte > name')?.textContent ||
                  null;
              if (metaName) name = metaName;
              addTrack(geojson, xml, name);
          } catch (err) {
              showAlert('Błąd wczytywania pliku "' + f.name + '": ' + err.message, 'danger', 7000);
          }
      }
      // wyczyść input, żeby można było ponownie wczytać te same pliki jeśli potrzeba
      gpxFilesInput.value = '';
  });

  // ---------- Wczytanie z URL ----------
  loadUrlBtn.addEventListener('click', async () => {
      const url = gpxUrlInput.value.trim();
      if (!url) return showAlert('Podaj URL pliku GPX.', 'warning');
      try {
          showAlert('Pobieram: ' + url, 'info', 2500);
          const res = await fetch(url);
          if (!res.ok) throw new Error('HTTP ' + res.status);
          const text = await res.text();
          const {
              geojson
          } = await parseGpxText(text);
          // spróbuj wyciągnąć nazwę z XML
          const parser = new DOMParser();
          const xml = parser.parseFromString(text, "application/xml");
          let name = url.split('/').pop() || url;
          const metaName = xml.querySelector('metadata > name')?.textContent ||
              xml.querySelector('trk > name')?.textContent ||
              xml.querySelector('rte > name')?.textContent ||
              null;
          if (metaName) name = metaName;
          addTrack(geojson, xml, name);
      } catch (err) {
          showAlert('Błąd pobierania/parowania z URL: ' + err.message, 'danger', 7000);
          console.error(err);
      }
      gpxUrlInput.value = '';
  });

  // ---------- Clear all ----------
  clearAllBtn.onclick = () => {
      for (const t of tracks) {
          try {
              map.removeLayer(t.layer);
          } catch (e) {}
      }
      tracks.length = 0;
      refreshTracksList();
      updateChart();
      const tbody = document.querySelector("#summaryTable tbody");
      tbody.innerHTML = '';
      showAlert('Usunięto wszystkie trasy', 'secondary', 2000);
  };

  // ---------- Przydatne: zoom to all ----------
  function fitAllTracks() {
      let allBounds = null;
      for (const t of tracks) {
          if (!t.bounds || !t.bounds.isValid()) continue;
          if (!allBounds) allBounds = t.bounds;
          else allBounds = allBounds.extend(t.bounds);
      }
      if (allBounds && allBounds.isValid()) map.fitBounds(allBounds.pad(0.1));
  }

  // ---------- Obsługa drag & drop (opcjonalnie) ----------
  ;
  (function enableDragDrop() {
      const dropArea = document.body;
      dropArea.addEventListener('dragover', (e) => {
          e.preventDefault();
          dropArea.classList.add('dragging');
      });
      dropArea.addEventListener('dragleave', (e) => {
          dropArea.classList.remove('dragging');
      });
      dropArea.addEventListener('drop', async (e) => {
          e.preventDefault();
          dropArea.classList.remove('dragging');
          const dt = e.dataTransfer;
          if (!dt) return;
          const files = Array.from(dt.files || []).filter(f => f.name && f.name.toLowerCase().endsWith('.gpx'));
          if (files.length) {
              // przypisz do input i wywołaj change
              gpxFilesInput.files = dt.files;
              const ev = new Event('change');
              gpxFilesInput.dispatchEvent(ev);
          } else {
              showAlert('Upuść plik(i) GPX lub użyj formularza.', 'info', 2500);
          }
      });
  })();

  // ---------- gotowe ----------
  showAlert('Gotowe. Wczytaj plik GPX lub podaj URL.', 'info', 4000);

</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.

13 października 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.