Kreator Tras GPX / KML
Twórz własne trasy GPS w kilka sekund!
Dzięki tej prostej aplikacji możesz w intuicyjny sposób rysować trasy na mapie OpenStreetMap, edytować je, a następnie eksportować do plików GPX lub KML — zgodnych z popularnymi aplikacjami i urządzeniami GPS (np. Garmin, Strava, Locus, Komoot).
Dlaczego warto skorzystać?
- Działa w przeglądarce — bez instalacji, w pełni lokalnie.
- Intuicyjna obsługa — przeciągnij, kliknij i gotowe!
- Eksport do GPX/KML — trasy możesz wykorzystać w dowolnym oprogramowaniu GPS.
- Import istniejących tras — wczytaj plik GPX/KML i edytuj go na mapie.
- Edycja i czyszczenie trasy — usuwaj, dodawaj lub przesuwaj punkty.
- Podgląd punktów — w bocznym panelu zobaczysz listę wszystkich współrzędnych.
Jak korzystać z aplikacji
- Na mapie pojawi się pasek narzędzi po lewej stronie:
- Kliknij ikonę linii (Polyline), aby rozpocząć rysowanie trasy.
- Klikaj na mapie, by dodawać kolejne punkty trasy.
- Kliknij „Zakończ”, aby zapisać linię.
- W panelu bocznym po prawej stronie zobaczysz:
- Listę punktów z ich współrzędnymi,
- Możliwość kliknięcia, aby przejść do danego punktu,
- Użyj przycisków poniżej mapy:
- Importuj GPX/KML — wczytaj istniejącą trasę,
- Eksportuj GPX — zapisz trasę w formacie GPX,
- Eksportuj KML — zapisz trasę w formacie KML,
- Wyczyść trasę — usuń wszystkie punkty i zacznij od nowa.
- Po edycji możesz plik zachować na komputerze i wgrać go do swojej aplikacji GPS.
Wskazówki
- Kliknij punkt w panelu bocznym, aby przybliżyć mapę do jego lokalizacji.
- Możesz przeciągać punkty i linie, by precyzyjnie ustalić trasę.
- Import działa zarówno dla plików GPX, jak i KML (np. z Garmina, Stravy lub Google Earth).
- Wszystkie dane przetwarzane są lokalnie w Twojej przeglądarce — nie są nigdzie wysyłane.
- Aplikacja łączy nowo dodane odcinki trasy eliminujac przerwy między nimi. Nie musisz dokładnie szukać punktu końcowego ostatniego odcinka.
- Aplikacja nie uwzględnia wysokości terenu – trasy są rysowane w oparciu o współrzędne poziome (2D). Pliki GPX i KML nie zawierają danych o przewyższeniach (elevation).
- Przyjmujemy, że teren jest płaski, dlatego dystans i szacowany profil trasy mogą się różnić od rzeczywistych.
- Przyjmujemy, że pieszy porusza się ze średnią prędkością 5 km/h, rower 20 km/h a samochód 60 km/h.
- Przejezdność dróg (np. stan nawierzchni, błoto, zamknięcia leśne, szlabany, prywatne tereny) nie jest weryfikowana automatycznie – użytkownik powinien sprawdzić trasę przed wyjazdem.
- Dane mapowe pochodzą z OpenStreetMap i mogą zawierać błędy lub nieaktualne informacje.
- Aplikacja nie uwzględnia warunków pogodowych ani pory roku (np. zalane odcinki, śnieg).
- Używaj trasy na własną odpowiedzialność i zawsze przestrzegaj lokalnych przepisów ruchu oraz zasad poruszania się po lasach i terenach prywatnych.
Długość trasy
Czas przejazdu
Podgląd pliku
Kod po stronie przeglądarki
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
<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>
#app { overflow-x: hidden; }
#map { height: 80vh; border-radius: 10px; }
#sidebar {
padding: 1rem;
max-height: 50vh;
overflow-y: auto;
}
.point-item {
padding: 0.3rem 0.6rem;
margin-bottom: 0.4rem;
border: 1px solid var(--bs-gray-500);
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.point-item:hover { background: var(--bs-body-bg-alt); }
/* 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">
<div class="col-lg-9">
<div id="map"></div>
<div class="mt-3 d-flex justify-content-center gap-2 flex-wrap">
<button id="importBtn" class="btn btn-secondary"><i class="bi bi-upload"></i> Importuj GPX/KML</button>
<button id="clearBtn" class="btn btn-danger"><i class="bi bi-x-circle"></i> Wyczyść trasę</button>
<button id="exportGPX" class="btn btn-primary"><i class="bi bi-download"></i> Eksportuj GPX</button>
<button id="exportKML" class="btn btn-success"><i class="bi bi-download"></i> Eksportuj KML</button>
</div>
</div>
<div class="col-lg-3">
<div id="sidebar">
<h3 class="h5 mb-3"><i class="bi bi-pin-map"></i> Punkty trasy</h3>
<div id="pointsList" class="small text-muted">
Brak punktów.
</div>
</div>
<div class="px-3 mt-3">
<h3 class="h5"><i class="bi bi-rulers"></i> Długość trasy</h3>
<div id="totalDistance"></div>
</div>
<div class="px-3 mt-3">
<h3 class="h5"><i class="bi bi-stopwatch"></i> Czas przejazdu</h3>
<div>
<i class="bi bi-car-front"></i> <span id="carTime">---</span>
</div>
<div>
<i class="bi bi-bicycle"></i> <span id="bikeTime">---</span>
</div>
<div>
<i class="bi bi-person-walking"></i> <span id="walkTime">---</span>
</div>
</div>
</div>
</div>
<div class="mt-4">
<h3 class="h5">Podgląd pliku</h3>
<div class="d-flex align-items-center gap-2 mb-2">
<label for="previewFormat" class="form-label mb-0">Format:</label>
<select id="previewFormat" class="form-select w-auto">
<option value="gpx" selected>GPX</option>
<option value="kml">KML</option>
</select>
<button id="copyPreview" class="btn btn-outline-secondary btn-sm">Kopiuj</button>
</div>
<pre><code id="filePreview" class="language-xml" style="max-height: 400px; overflow-y: auto;"></code></pre>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.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://unpkg.com/leaflet-control-geocoder/dist/Control.Geocoder.js"></script>
<script>
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
});
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 map = L.map('map', {
center: [52.1, 19.4],
zoom: 6,
layers: [osm]
});
osm.addTo(map);
const baseLayers = {
"OpenStreetMap": osm,
"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.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);
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);
// 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);
let drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
var lastEndPoint = null;
const drawControl = new L.Control.Draw({
edit: {
featureGroup: drawnItems
},
draw: {
polygon: false,
rectangle: false,
circle: false,
circlemarker: false,
marker: true,
polyline: {
shapeOptions: {
color: '#0d6efd'
}
}
}
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, e => {
drawnItems.addLayer(e.layer);
updatePointsList();
});
map.on(L.Draw.Event.EDITED, updatePointsList);
map.on(L.Draw.Event.DELETED, updatePointsList);
function getAllPoints() {
const latlngs = [];
drawnItems.eachLayer(layer => {
if (layer instanceof L.Polyline) layer.getLatLngs().forEach(p => latlngs.push(p));
else if (layer instanceof L.Marker) latlngs.push(layer.getLatLng());
});
return latlngs;
}
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));
}
/**
* Szacuje czas przejazdu/przejścia dla danej odległości.
* @param {number} distanceKm - dystans w kilometrach
* @param {"car" | "bike" | "walk"} mode - tryb podróży
* @returns {string} czas w formacie "X h Y min"
*/
function estimateTravelTime(distanceKm, mode) {
const speeds = {
car: 60, // km/h
bike: 20,
walk: 5
};
if (!speeds[mode]) {
throw new Error("Nieznany tryb podróży. Użyj: car, bike, walk.");
}
const hours = distanceKm / speeds[mode];
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return `${h} h ${m} min`;
}
function updatePointsList() {
const container = document.getElementById('pointsList');
const totalDist = document.getElementById('totalDistance');
const carTime = document.getElementById('carTime');
const bikeTime = document.getElementById('bikeTime');
const walkTime = document.getElementById('walkTime');
const pts = getAllPoints();
let totalDistance = 0;
let lastLat = 0;
let lastLng = 0;
if (!pts.length) {
container.innerHTML = "Brak punktów.";
totalDist.innerHTML = 'Brak trasy';
carTime.innerHTML = '---';
bikeTime.innerHTML = '---';
walkTime.innerHTML = '---';
return;
}
container.innerHTML = "";
pts.forEach((p, idx) => {
const div = document.createElement('div');
div.className = "point-item";
div.innerHTML = `
<span><b>${idx + 1}.</b> ${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}</span>
`;
div.onclick = () => map.setView(p, 15);
container.appendChild(div);
if (idx > 0) {
totalDistance += haversineDistanceKm(lastLat, lastLng, p.lat, p.lng);
}
lastLat = p.lat;
lastLng = p.lng;
});
const carTravelTime = estimateTravelTime(totalDistance, 'car');
const bikeTravelTime = estimateTravelTime(totalDistance, 'bike');
const walkTravelTime = estimateTravelTime(totalDistance, 'walk');
totalDist.innerHTML = totalDistance.toFixed(2) + ' km';
carTime.innerHTML = carTravelTime;
bikeTime.innerHTML = bikeTravelTime;
walkTime.innerHTML = walkTravelTime;
}
function removePoint(index) {
drawnItems.eachLayer(layer => {
if (layer instanceof L.Polyline) {
let latlngs = layer.getLatLngs();
if (latlngs.length > index) {
latlngs.splice(index, 1);
layer.setLatLngs(latlngs);
}
}
});
updatePointsList();
}
function download(filename, text) {
const blob = new Blob([text], {
type: 'text/xml'
});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
}
document.getElementById('exportGPX').onclick = () => {
const pts = getAllPoints();
if (!pts.length) return alert('Brak punktów trasy!');
const gpx = `<?xml version="1.0"?>
<gpx version="1.1" creator="Kreator GPX">
<trk>
<name>Nowa trasa</name>
<trkseg>
${pts.map(p => `<trkpt lat="${p.lat}" lon="${p.lng}"></trkpt>`).join('\n ')}
</trkseg>
</trk>
</gpx>`;
download('trasa.gpx', gpx);
};
document.getElementById('exportKML').onclick = () => {
const pts = getAllPoints();
if (!pts.length) return alert('Brak punktów trasy!');
const kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Nowa trasa</name>
<Placemark>
<LineString>
<coordinates>
${pts.map(p => `${p.lng},${p.lat},0`).join('\n ')}
</coordinates>
</LineString>
</Placemark>
</Document>
</kml>`;
download('trasa.kml', kml);
};
// aktualizacja listy na start
updatePointsList();
// === 🔁 Automatyczny podgląd GPX / KML z kolorowaniem ===
const previewCode = document.getElementById('filePreview');
const formatSelect = document.getElementById('previewFormat');
const copyBtn = document.getElementById('copyPreview');
function generatePreview() {
const pts = getAllPoints();
if (!pts.length) {
previewCode.textContent = "// Brak punktów trasy do wyświetlenia";
return;
}
let xml = "";
if (formatSelect.value === "gpx") {
xml = `<?xml version="1.0"?>
<gpx version="1.1" creator="Kreator GPX">
<trk>
<name>Podgląd trasy</name>
<trkseg>
${pts.map(p => `<trkpt lat="${p.lat}" lon="${p.lng}"></trkpt>`).join('\n ')}
</trkseg>
</trk>
</gpx>`;
} else {
xml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Podgląd trasy</name>
<Placemark>
<LineString>
<coordinates>
${pts.map(p => `${p.lng},${p.lat},0`).join('\n ')}
</coordinates>
</LineString>
</Placemark>
</Document>
</kml>`;
}
if (previewCode.hasAttribute('data-highlighted'))
previewCode.removeAttribute('data-highlighted');
if (previewCode.hasAttribute('data-highlighter'))
previewCode.removeAttribute('data-highlighter');
if (previewCode.classList.contains('hljs'))
previewCode.classList.remove(...Array.from(previewCode.classList).filter(cls => cls.startsWith('hljs')));
previewCode.textContent = xml.trim();
hljs.highlightElement(previewCode);
}
// 📋 Kopiowanie z fallbackiem
copyBtn.onclick = () => {
const text = previewCode.textContent;
// Nowoczesny API (działa w HTTPS i localhost)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text)
.then(() => BootstrapToast.show({
title: 'Powiadomienie',
message: "Skopiowano kod " + formatSelect.value.toUpperCase() + " do schowka!",
when: 'teraz',
type: 'info'
}))
.catch(err => BootstrapToast.show({
title: 'Niepowodzenie',
message: "Błąd kopiowania: " + err,
when: 'teraz',
type: 'danger'
}))
} else {
// Fallback dla plików lokalnych (file://)
const temp = document.createElement("textarea");
temp.value = text;
document.body.appendChild(temp);
temp.select();
try {
document.execCommand("copy");
BootstrapToast.show({
title: 'Powiadomienie',
message: "Skopiowano kod " + formatSelect.value.toUpperCase() + " do schowka!",
when: 'teraz',
type: 'info'
});
} catch (err) {
BootstrapToast.show({
title: 'Niepowodzenie',
message: "Nie udało się skopiować: " + err,
when: 'teraz',
type: 'danger'
});
}
document.body.removeChild(temp);
}
};
// 🔄 Aktualizacja przy zmianach
formatSelect.onchange = generatePreview;
document.addEventListener('DOMContentLoaded', generatePreview);
// Pomocnicza funkcja do aktualizacji punktu końcowego
function updateLastEndPoint() {
const allPts = getAllPoints();
lastEndPoint = allPts.length ? allPts[allPts.length - 1] : null;
}
// Obsługa rysowania nowego segmentu
map.on(L.Draw.Event.CREATED, e => {
const layer = e.layer;
if (layer instanceof L.Polyline) {
const latlngs = layer.getLatLngs();
// jeśli istnieje wcześniejszy punkt końcowy — sklej
if (lastEndPoint) {
latlngs.unshift(lastEndPoint);
layer.setLatLngs(latlngs);
}
drawnItems.addLayer(layer);
updateLastEndPoint();
} else if (layer instanceof L.Marker) {
drawnItems.addLayer(layer);
updateLastEndPoint();
}
updatePointsList();
generatePreview();
});
// Po edycji — aktualizuj koniec trasy
map.on(L.Draw.Event.EDITED, () => {
updateLastEndPoint();
updatePointsList();
generatePreview();
});
// Po usunięciu — aktualizuj koniec trasy
map.on(L.Draw.Event.DELETED, () => {
updateLastEndPoint();
updatePointsList();
generatePreview();
});
// Po imporcie — ustaw koniec trasy
function importTrack(xml, format) {
let geojson;
if (format === "gpx") geojson = toGeoJSON.gpx(xml);
else if (format === "kml") geojson = toGeoJSON.kml(xml);
else return alert("Nieobsługiwany format pliku.");
drawnItems.clearLayers();
geojson.features.forEach(f => {
if (f.geometry.type === "LineString") {
const coords = f.geometry.coordinates.map(c => [c[1], c[0]]);
L.polyline(coords, {
color: "blue"
}).addTo(drawnItems);
} else if (f.geometry.type === "Point") {
const [lng, lat] = f.geometry.coordinates;
L.marker([lat, lng]).addTo(drawnItems);
}
});
map.fitBounds(drawnItems.getBounds());
updatePointsList();
updateLastEndPoint(); // 🟢 kluczowe po imporcie
generatePreview();
}
// Zmieniamy obsługę wczytywania pliku
document.getElementById("importBtn").onclick = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".gpx,.kml";
input.onchange = e => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = evt => {
const text = evt.target.result;
const xml = new DOMParser().parseFromString(text, "text/xml");
importTrack(xml, file.name.endsWith(".gpx") ? "gpx" : "kml");
};
reader.readAsText(file);
};
input.click();
};
document.getElementById('clearBtn').onclick = () => {
drawnItems.clearLayers();
lastEndPoint = null; // resetuj punkt końcowy
updatePointsList();
generatePreview();
};
</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.