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
.gpx
z 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
Lista tras
Profil wysokości
Nazwa trasy | Długość [km] | Przewyższenie + [m] | Średnie nachylenie [%] |
---|
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" />
<style>
#map { height: 70vh; min-height: 400px; }
.track-item { cursor: pointer; }
[data-bs-theme=golden] .btn-outline-danger {
--bs-btn-color: #d13241;
--bs-btn-border-color: #d13241;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #d13241;
--bs-btn-hover-border-color: #d13241;
--bs-btn-focus-shadow-rgb: 220, 53, 69;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #d13241;
--bs-btn-active-border-color: #d13241;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #d13241;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #d13241;
--bs-gradient: none;
}
[data-bs-theme=twilight] .btn-outline-danger {
--bs-btn-color: #ff4c65;
--bs-btn-border-color: #ff4c65;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #ff4c65;
--bs-btn-hover-border-color: #ff4c65;
--bs-btn-focus-shadow-rgb: 220, 53, 69;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #ff4c65;
--bs-btn-active-border-color: #ff4c65;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #ff4c65;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #ff4c65;
--bs-gradient: none;
}
[data-bs-theme=dark] .btn-outline-danger {
--bs-btn-color: #ff4359;
--bs-btn-border-color: #ff4359;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #ff4359;
--bs-btn-hover-border-color: #ff4359;
--bs-btn-focus-shadow-rgb: 220, 53, 69;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #ff4359;
--bs-btn-active-border-color: #ff4359;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #ff4359;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #ff4359;
--bs-gradient: none;
}
[data-bs-theme=twilight] .btn-outline-secondary {
--bs-btn-color: #8d9aa4;
--bs-btn-border-color: #8d9aa4;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #8d9aa4;
--bs-btn-hover-border-color: #8d9aa4;
--bs-btn-focus-shadow-rgb: 108, 117, 125;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #8d9aa4 ;
--bs-btn-active-border-color: #8d9aa4;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #8d9aa4;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #8d9aa4;
--bs-gradient: none;
}
[data-bs-theme=dark] .btn-outline-secondary {
--bs-btn-color: #87939d;
--bs-btn-border-color: #87939d;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #87939d;
--bs-btn-hover-border-color: #87939d;
--bs-btn-focus-shadow-rgb: 108, 117, 125;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #87939d ;
--bs-btn-active-border-color: #87939d;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #87939d;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #87939d;
--bs-gradient: none;
}
</style>
<div id="app">
<div class="row g-3 mb-3">
<div class="col-md-5">
<div class="card">
<div class="card-body">
<h3 class="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">
<h5 class="card-title">Lista tras</h5>
<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">
<h5 class="card-title">Profil wysokości</h5>
<canvas id="elevationChart" height="150"></canvas>
</div>
</div>
<!-- --- KONIEC --- -->
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<strong><i class="bi bi-bar-chart"></i> Podsumowanie trasy</strong>
</div>
<div class="card-body p-0">
<table class="table table-striped mb-0" id="summaryTable">
<thead>
<tr>
<th>Nazwa trasy</th>
<th>Długość [km]</th>
<th>Przewyższenie + [m]</th>
<th>Średnie nachylenie [%]</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>
// ---------- Inicjalizacja mapy ----------
const map = L.map('map', { preferCanvas: true }).setView([52.2297, 21.0122], 13); // domyślnie Warszawa
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Kontenery
const tracks = []; // { id, name, layer, color, lengthKm, bounds }
const colors = [
'#0d6efd', // primary
'#6c757d', // secondary
'#198754', // success
'#dc3545', // danger
'#ffc107', // warning
'#0dcaf0', // info
'#6610f2', // purple (Bootstrap accent)
'#fd7e14', // orange
'#20c997', // teal
'#212529' // dark
];
// 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="Close"></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 };
}
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;
if (!anyEle) {
// tylko dystans
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, totalAscentM: null, totalDescentM: null, avgSlopePct: null };
}
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) && !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;
}
const avgSlopePct = ascent > 0 && totalDistance > 0
? (ascent / (totalDistance * 1000)) * 100
: 0;
return {
distanceKm: totalDistance.toFixed(2),
totalAscentM: Math.round(ascent),
totalDescentM: Math.round(descent),
avgSlopePct: avgSlopePct.toFixed(2)
};
}
// ========================
// 📝 Dodawanie wyników do tabeli
// ========================
function addSummaryRow(geojson) {
const s = analyzeGeoJSON(geojson, { smoothingWindow: 7, minElevationChange: 3 });
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.avgSlopePct} %</td>
`;
tbody.appendChild(row);
}
// ---------- Dodaj trasę na mapę ----------
function addTrack(geojson, xml, metaName) {
// Wybierz kolor cyklicznie
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);
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);
</script>
Kod po stronie serwera
Informacja
Aplikacja nie korzysta z kodu po stronie serwera.