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;
}
.marker-start { filter: hue-rotate(270deg); }
.marker-end { filter: hue-rotate(160deg); }
</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
]
];
const startIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
className: 'marker-start'
});
const endIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
className: 'marker-end'
});
// 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);
}
let markers = [];
// ---------- 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);
// Dodaj markery start / meta
trackLayer.eachLayer(function(layer) {
if (layer instanceof L.Polyline) {
const latlngs = layer.getLatLngs();
// Obsługa MultiLineString (czasem GPX tak zwraca)
const flat = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
if (flat.length > 0) {
// START
const markerStart = L.marker(flat[0], {
icon: startIcon
}).addTo(map).bindPopup("Start");
// META
const markerEnd = L.marker(flat[flat.length - 1], {
icon: endIcon
}).addTo(map).bindPopup("Meta");
markers.push(markerStart);
markers.push(markerEnd);
}
}
});
// 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);
for (let i = 0; i < markers.length; i++) {
map.removeLayer(markers[i]);
}
markers = [];
} 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.
Licencja
## BSD-3-Clause License Agreement
BSD-3-Clause
Сopyright (c) 2026 Dariusz Rorat
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.