Jak stworzyć przeglądarkową nawigację rowerową GPS w HTML, JavaScript i Bootstrap
Współczesne przeglądarki dają nam dostęp do wielu zaawansowanych funkcji urządzenia — w tym geolokalizacji, kompasu, głosu i plików. Możemy więc zbudować prostą aplikację nawigacji rowerowej GPS, która działa bez instalacji, prosto w przeglądarce smartfona.
W tym artykule krok po kroku pokażę, jak zbudować taką aplikację z użyciem HTML5, Bootstrap 5.3, Leaflet (OpenStreetMap) i JavaScript, a także jak dodać funkcje:
- wczytywania tras GPX / KML,
- śledzenia pozycji GPS w czasie rzeczywistym,
- wskazówek graficznych i głosowych,
- kompasu,
- licznika prędkości i dystansu.
1. Struktura projektu
Wystarczy jeden plik index.html
. W kodzie umieszczamy:
- nagłówki z odwołaniami do Bootstrap i Leaflet (mapa),
- prosty interfejs użytkownika,
- kontener na mapę i kompas,
- oraz sekcję
<script>
z logiką aplikacji.
Układ strony:
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Nawigacja rowerowa GPX</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<!-- interfejs, mapa, kompas -->
</body>
</html>
2. Interfejs użytkownika
W prostym panelu umieszczamy przyciski, które pozwalają:
- wczytać plik GPX/KML z trasą,
- rozpocząć i zakończyć śledzenie GPS.
Dodatkowo dodajemy kompas i HUD (licznik):
<div id="controls" class="text-center">
<h6 class="mb-2">🗺️ Nawigacja GPX</h6>
<input id="file" type="file" accept=".gpx,.kml" class="form-control form-control-sm mb-3"/>
<div class="d-flex justify-content-center gap-2 mb-2">
<button id="startBtn" class="btn btn-success btn-sm px-3">▶️ Rozpocznij</button>
<button id="stopBtn" class="btn btn-danger btn-sm px-3">⏹️ Zakończ</button>
</div>
<div class="text-start small">
<div id="status">Status: oczekiwanie</div>
<div id="info">Trasa: —</div>
</div>
</div>
<div id="map"></div>
<div id="compass"><div id="arrow"></div></div>
<div id="hud">
<div id="speed">0 km/h</div>
<div id="distance">Dystans: 0.00 km</div>
</div>
Całość stylizujemy przy pomocy prostego CSS, dbając o widoczność na smartfonie (duże przyciski, kontrastowe kolory, zaokrąglone panele).
3. Inicjalizacja mapy
Do wizualizacji mapy używamy biblioteki Leaflet z podkładem OpenStreetMap:
const map = L.map('map').setView([52.2297, 21.0122], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map);
4. Wczytywanie trasy GPX / KML
Do odczytu śladów GPS używamy biblioteki toGeoJSON.
Po wczytaniu pliku użytkownika konwertujemy go na obiekt GeoJSON i wyświetlamy na mapie.
function handleFile(ev){
const f = ev.target.files[0];
if(!f) return;
const reader = new FileReader();
reader.onload = e => {
const xml = new DOMParser().parseFromString(e.target.result, "text/xml");
const gj = xml.documentElement.nodeName.toLowerCase().includes('gpx') ?
toGeoJSON.gpx(xml) : toGeoJSON.kml(xml);
const line = gj.features.find(f => f.geometry.type === 'LineString');
routeLayer = L.geoJSON(line, {style:{color:'blue',weight:4}}).addTo(map);
map.fitBounds(routeLayer.getBounds());
};
reader.readAsText(f);
}
W efekcie wczytana trasa pojawia się jako niebieska linia.
5. Śledzenie pozycji GPS
Funkcja navigator.geolocation.watchPosition()
pozwala uzyskać bieżące położenie użytkownika.
Każda nowa pozycja jest aktualizowana na mapie, a dane przetwarzane w czasie rzeczywistym:
watchId = navigator.geolocation.watchPosition(onPos, onErr, {
enableHighAccuracy: true, maximumAge: 1000, timeout: 10000
});
Funkcja onPos()
:
- aktualizuje marker rowerzysty,
- oblicza dystans od trasy,
- wyświetla prędkość i sumaryczny dystans,
- generuje wskazówki graficzne i głosowe.
6. Kompas i kierunek jazdy
Kompas w postaci prostego trójkąta obracamy w kierunku trasy za pomocą transformacji CSS:
function rotateArrow(bearing){
arrow.style.transform = `rotate(${bearing}deg)`;
}
Wartość bearing
obliczana jest przy użyciu biblioteki Turf.js:
const bearing = turf.bearing(pt, snapped);
7. Komunikaty głosowe i graficzne
System rozpoznaje skręty i komunikaty co pewien czas.
Jeśli kierunek zmienia się o więcej niż 35°, odtwarzany jest głos i pokazuje się duża strzałka na ekranie.
function speak(txt){
const utter = new SpeechSynthesisUtterance(txt);
utter.lang = 'pl-PL';
speechSynthesis.speak(utter);
}
function showTurn(type){
let icon = type === 'lewo' ? '⬅️' :
type === 'prawo' ? '➡️' :
type === 'off' ? '⚠️' : '⬆️';
turnArrow.textContent = icon;
overlay.classList.add('show');
setTimeout(()=>overlay.classList.remove('show'),2000);
}
8. Licznik prędkości i dystansu
Wartości te są wyświetlane w panelu HUD.
Prędkość pochodzi z pos.coords.speed
, a dystans sumujemy z kolejnych punktów GPS:
if(lastPos){
const dist = turf.distance(turf.point(lastPos), pt, {units:'kilometers'});
totalDistance += dist;
}
speedEl.textContent = `${(pos.coords.speed * 3.6).toFixed(1)} km/h`;
distEl.textContent = `Dystans: ${totalDistance.toFixed(2)} km`;
9. Wymóg HTTPS
Aplikacja korzysta z geolokalizacji i syntezy mowy, dlatego musi działać w kontekście bezpiecznym (HTTPS).
Przeglądarki blokują te funkcje, gdy strona działa na zwykłym HTTP lub otwierana jest lokalnie przez file:///
.
✅ Działa:
https://twojadomena.pl
http://localhost
❌ Nie działa:
http://twojadomena.pl
file:///C:/index.html
10. Efekt końcowy
Po uruchomieniu aplikacja umożliwia:
- wczytanie własnego śladu GPX/KML,
- rozpoczęcie śledzenia GPS,
- prowadzenie po trasie z komunikatami głosowymi i graficznymi,
- podgląd kompasu, prędkości i dystansu.
Działa w każdej nowoczesnej przeglądarce mobilnej (Chrome, Edge, Firefox, Safari).
11. Gotowy kod aplikacji
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Nawigacja rowerowa GPX z licznikiem</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
html,body,#map {height:100%;margin:0;}
#controls{
position:absolute;top:10px;left:10px;z-index:1000;
background:white;box-shadow:0 4px 10px rgba(0,0,0,0.25);
border-radius:1rem;padding:1rem;min-width:220px;
}
#compass{
position:absolute;bottom:100px;right:20px;z-index:1000;
width:80px;height:80px;border-radius:50%;
background:rgba(255,255,255,0.9);box-shadow:0 4px 10px rgba(0,0,0,0.3);
display:flex;align-items:center;justify-content:center;
}
#arrow{
width:0;height:0;border-left:10px solid transparent;
border-right:10px solid transparent;border-bottom:30px solid red;
transform-origin:center 25px;transition:transform 0.5s ease;
}
#turnOverlay{
position:absolute;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;
z-index:1500;opacity:0;pointer-events:none;transition:opacity 0.5s ease;
}
#turnArrow{
font-size:100px;color:white;text-shadow:0 0 15px rgba(0,0,0,0.6);
transform:scale(0.8);transition:transform 0.5s ease;
}
#turnOverlay.show{opacity:1;pointer-events:auto;}
#turnOverlay.show #turnArrow{transform:scale(1);}
#hud{
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
background:rgba(255,255,255,0.9);padding:0.8rem 1.2rem;border-radius:1rem;
box-shadow:0 4px 10px rgba(0,0,0,0.25);z-index:1200;text-align:center;
}
#speed{font-size:1.5rem;font-weight:bold;}
#distance{font-size:1rem;color:#333;}
</style>
</head>
<body>
<div id="controls" class="text-center">
<h6 class="mb-2">🗺️ Nawigacja GPX</h6>
<input id="file" type="file" accept=".gpx,.kml" class="form-control form-control-sm mb-3"/>
<div class="d-flex justify-content-center gap-2 mb-2">
<button id="startBtn" class="btn btn-success btn-sm px-3">
▶️ Rozpocznij
</button>
<button id="stopBtn" class="btn btn-danger btn-sm px-3">
⏹️ Zakończ
</button>
</div>
<div class="text-start small">
<div id="status">Status: oczekiwanie</div>
<div id="info">Trasa: —</div>
</div>
</div>
<div id="map"></div>
<div id="compass"><div id="arrow"></div></div>
<div id="turnOverlay"><div id="turnArrow">⬆️</div></div>
<div id="hud">
<div id="speed">0 km/h</div>
<div id="distance">Dystans: 0.00 km</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/togeojson/0.16.0/togeojson.min.js"></script>
<script src="https://unpkg.com/@turf/turf@6.5.0/turf.min.js"></script>
<script>
const map = L.map('map').setView([52.2297,21.0122], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom:19, attribution:'© OpenStreetMap'
}).addTo(map);
let routeLine=null, routeLayer=null, riderMarker=null;
let watchId=null, lastBearing=null, lastVoiceTime=0;
let totalDistance=0, lastPos=null;
const statusEl=document.getElementById('status');
const infoEl=document.getElementById('info');
const arrow=document.getElementById('arrow');
const overlay=document.getElementById('turnOverlay');
const turnArrow=document.getElementById('turnArrow');
const speedEl=document.getElementById('speed');
const distEl=document.getElementById('distance');
document.getElementById('file').addEventListener('change', handleFile);
document.getElementById('startBtn').addEventListener('click', startTracking);
document.getElementById('stopBtn').addEventListener('click', stopTracking);
function handleFile(ev){
const f=ev.target.files[0];
if(!f)return;
const reader=new FileReader();
reader.onload=e=>{
const xml=new DOMParser().parseFromString(e.target.result,"text/xml");
const gj = xml.documentElement.nodeName.toLowerCase().includes('gpx') ? toGeoJSON.gpx(xml) : toGeoJSON.kml(xml);
const line = gj.features.find(f=>f.geometry.type==='LineString'||f.geometry.type==='MultiLineString');
if(!line){alert('Nie znaleziono ścieżki w pliku.');return;}
if(line.geometry.type==='MultiLineString'){
line.geometry=turf.flatten(line).features[0].geometry;
}
routeLine=line;
if(routeLayer)map.removeLayer(routeLayer);
routeLayer=L.geoJSON(routeLine,{style:{color:'blue',weight:4}}).addTo(map);
map.fitBounds(routeLayer.getBounds());
statusEl.textContent='Trasa załadowana';
infoEl.textContent=`Punkty: ${routeLine.geometry.coordinates.length}`;
};
reader.readAsText(f);
}
function startTracking(){
if(!routeLine){alert('Najpierw wczytaj trasę!');return;}
if(!navigator.geolocation){alert('Brak geolokalizacji');return;}
totalDistance=0;
lastPos=null;
watchId=navigator.geolocation.watchPosition(onPos,onErr,{enableHighAccuracy:true,maximumAge:1000,timeout:10000});
statusEl.textContent='Śledzenie włączone';
}
function stopTracking(){
if(watchId!==null){navigator.geolocation.clearWatch(watchId);watchId=null;}
statusEl.textContent='Śledzenie zatrzymane';
}
function onErr(err){statusEl.textContent='Błąd GPS: '+err.message;}
function onPos(pos){
const lat=pos.coords.latitude, lon=pos.coords.longitude;
const pt=turf.point([lon,lat]);
if(!riderMarker)riderMarker=L.circleMarker([lat,lon],{radius:6,color:'red'}).addTo(map);
else riderMarker.setLatLng([lat,lon]);
// licz prędkość i dystans
if(lastPos){
const dist = turf.distance(turf.point(lastPos), pt, {units:'kilometers'});
totalDistance += dist;
}
lastPos = [lon, lat];
const speed = pos.coords.speed ? pos.coords.speed * 3.6 : 0; // m/s -> km/h
speedEl.textContent = `${speed.toFixed(1)} km/h`;
distEl.textContent = `Dystans: ${totalDistance.toFixed(2)} km`;
// prowadzenie
const snapped=turf.nearestPointOnLine(routeLine,pt,{units:'meters'});
const distToLine=turf.distance(pt,snapped,{units:'meters'});
const remaining=turf.length(turf.lineSlice(snapped,
turf.point(routeLine.geometry.coordinates.at(-1)),routeLine),{units:'kilometers'});
const bearing=turf.bearing(pt,snapped);
rotateArrow(bearing);
// komunikaty
if(lastBearing!==null){
const delta=Math.abs(bearing-lastBearing);
if(delta>35 && distToLine<30){
const turn=(bearing>lastBearing)?'prawo':'lewo';
showTurn(turn);
speak(`Skręć w ${turn}`);
} else if(Date.now()-lastVoiceTime>20000){
showTurn('prosto');
speak('Jedź prosto');
}
}
lastBearing=bearing;
if(distToLine>30){
statusEl.textContent=`POZA TRASĄ (${Math.round(distToLine)} m)`;
showTurn('off');
speak('Jesteś poza trasą, wróć na ślad!');
}else{
statusEl.textContent=`Na trasie (${Math.round(distToLine)} m od śladu)`;
}
infoEl.innerHTML=`Pozostało: ${remaining.toFixed(2)} km<br>Kierunek: ${Math.round(bearing)}°`;
map.panTo([lat,lon],{animate:true,duration:0.5});
}
function rotateArrow(bearing){
arrow.style.transform=`rotate(${bearing}deg)`;
}
function speak(txt){
const now=Date.now();
if(now-lastVoiceTime<4000)return;
lastVoiceTime=now;
const utter=new SpeechSynthesisUtterance(txt);
utter.lang='pl-PL';
speechSynthesis.cancel();
speechSynthesis.speak(utter);
}
function showTurn(type){
let icon='⬆️';
if(type==='lewo') icon='⬅️';
else if(type==='prawo') icon='➡️';
else if(type==='off') icon='⚠️';
else if(type==='prosto') icon='⬆️';
turnArrow.textContent=icon;
overlay.classList.add('show');
setTimeout(()=>overlay.classList.remove('show'),2000);
}
</script>
</body>
</html>
12. Możliwe rozszerzenia
- tryb nocny z ciemną mapą OSM,
- zapis historii przejazdu,
- możliwość pracy offline (PWA),
- eksport pokonanego śladu do GPX,
- obsługa tras wieloodcinkowych.
Podsumowanie
Ta prosta aplikacja pokazuje, jak potężne narzędzie stanowi dzisiejszy JavaScript w przeglądarce.
Bez instalacji, bez natywnego kodu — można stworzyć w pełni funkcjonalną nawigację rowerową z głosem, kompasem i licznikiem działającą bezpośrednio w smartfonie.
To doskonały projekt do nauki HTML5 API, pracy z mapami i geolokalizacją.
Wystarczy jedna strona — a otrzymujemy mini-GPS w kieszeni.