Przejdź do głównej treści
Grafika przedstawia ukryty obrazek

Jak stworzyć przeglądarkową nawigację rowerową GPS w HTML, JavaScript i Bootstrap

Grafika SVG Trasa

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: '&copy; 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:'&copy; 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.

18 października 2025 4

Kategorie

programowanie

Dziękujemy!
()

Powiązane wpisy

Wizualizacja tematu Long Polling vs Short Polling Porwnanie
21 stycznia 2025 5 min 26

Long Polling vs. Short Polling: Porównanie

Czytaj więcej
Ilustracja tematu Wyraenia regularne i ich obsuga w PHP oraz JavaScript
1 lutego 2025 5 min 19

Wyrażenia regularne i ich obsługa w PHP oraz JavaScript

Czytaj więcej
Grafika przedstawia Jak zrealizowa dwukierunkowe wizanie danych MVVM w Vanilla JS
2 lutego 2025 3 min 18

Jak zrealizować dwukierunkowe wiązanie danych MVVM w Vanilla JS?

Czytaj więcej
Wymiana doświadczeń

Masz podobne doświadczenia?

Chętnie poznam Twoją perspektywę i porozmawiam o tym temacie szerzej.

Napisz do mnie

Każda perspektywa może wnieść coś wartościowego do dyskusji.

Twoja prywatność i pliki cookies

  1. Ta strona internetowa wykorzystuje wyłącznie niezbędne pliki cookies, które są wymagane do jej prawidłowego działania – m.in. do poprawnego wyświetlania treści, zapamiętania podstawowych ustawień przeglądarki oraz zapewnienia stabilności serwisu.
  2. Nie stosuję plików cookies w celach marketingowych, reklamowych ani analitycznych.
  3. Strona ma charakter wyłącznie informacyjny i nie zawiera formularzy kontaktowych, rejestracyjnych ani zakupowych, przez które dane mogłyby być przesyłane na serwer.
  4. Nie zbieram danych osobowych podczas zwykłego korzystania z witryny.
  5. Serwis nie korzysta z certyfikatu SSL, jednak ze względu na informacyjny charakter strony nie jest wymagane przesyłanie poufnych danych. Zalecam jednak, aby nigdy nie wpisywać haseł ani danych osobowych na stronach bez szyfrowanego połączenia.
  6. Korzystając z tej strony, wyrażasz zgodę na używanie wyłącznie niezbędnych plików cookies.

Więcej informacji znajdziesz w mojej polityce prywatności.