GPX OpenStreetMap Viewer
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ć
- Wczytaj plik
.gpxz dysku lub wklej link do pliku z sieci. - 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
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.
| 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: '© Esri, Maxar, Earthstar Geographics'
});
const esriTopo = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', {
attribution: '© Esri'
});
const openTopo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
maxZoom: 17,
attribution: '© OpenTopoMap contributors'
});
// Nakładki tematyczne
const railways = L.tileLayer('https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', {
attribution: '© OpenRailwayMap contributors'
});
const seaports = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
attribution: '© OpenSeaMap contributors'
});
const hiking = L.tileLayer('https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png', {
attribution: '© 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('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
// ---------- 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.