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 2

Kategorie

programowanie

Dziękujemy!
()

Powiązane wpisy


Informacja o cookies

Moja strona internetowa wykorzystuje wyłącznie niezbędne pliki cookies, które są wymagane do jej prawidłowego działania. Nie używam ciasteczek w celach marketingowych ani analitycznych. Korzystając z mojej strony, wyrażasz zgodę na stosowanie tych plików. Możesz dowiedzieć się więcej w mojej polityce prywatności.