bw_on_iphone

Bringing BlitzWay to iOS: Building a Navigation PWA from Scratch (Developer Blog)

With over 10,000 downloads on Android, the most frequent question from the BlitzWay community has been straightforward: when is BlitzWay coming to iOS?

This post documents the technical journey of answering that question, not with a native iOS app, but with a Progressive Web App that runs on any device with a modern browser.


The Economics of iOS Development

Before diving into the technical implementation, it is worth explaining why we chose the PWA route.

BlitzWay is a personal project. There is no venture funding, no team of developers, no corporate backing. The app exists because I believe drivers deserve a free, privacy-respecting navigation tool with proper average speed zone tracking, speed camera alerts, and comunity reports.

Publishing on the Apple App Store requires:

  • Apple Developer Program membership: 99 EUR per year
  • A Mac for development: minimum 1,500 EUR for a MacBook capable of running Xcode
  • Complete rewrite of the Android codebase in Swift or Objective-C
  • Ongoing maintenance for iOS-specific APIs, permissions, and App Store compliance

The total cost exceeds 3,500 EUR upfront, with an annual fee of approximately 100 EUR just to keep the app listed. For a free application with no monetization beyond optional premium features, this cost structure does not make sense.

The PWA approach allows BlitzWay to run on iOS, Android, Windows, macOS, Linux you name it (even the infotainment system of Tesla cars) all from a single TypeScript codebase.


Technology Stack

The BlitzWay PWA is built on:

  • React 19 with TypeScript for the application layer
  • MapLibre GL JS for map rendering
  • Vite for builds with the vite-plugin-pwa for service worker generation
  • Workbox for offline caching strategies
  • Zustand for state management
  • Turf.js for geospatial calculations

The backend services remain the same as the Android app:

  • Valhalla for routing, turn-by-turn navigation, and speed limit lookup
  • TileServer GL for self-hosted vector and raster map tiles
  • Nominatim for geocoding and address search
  • Waze API for community traffic reports

The Core Challenge: Smooth Location Tracking

Native navigation apps have a significant advantage: direct access to device sensors with consistent, high-frequency updates. The browser's Geolocation API provides updates sporadically, often with visible jumps as the location marker teleports between GPS fixes.

Solving this required building a multi-layer location processing pipeline.

Layer 1: Raw GPS Acquisition

The browser's Geolocation API is configured for maximum accuracy:

navigator.geolocation.watchPosition(callback, errorCallback, {
  enableHighAccuracy: true,
  maximumAge: 0,
  timeout: 5000
});

Updates arrive approximately every 1-2 seconds, depending on the device and conditions. Each update includes latitude, longitude, and optionally speed and heading.

Layer 2: Map Matching (Road Snapping)

Raw GPS coordinates rarely land exactly on the road. A vehicle driving down a straight road will show a zigzag pattern as GPS error accumulates. The Android app solves this with Valhalla's map matching, we ported the same approach.

The mapMatchingService maintains a buffer of recent GPS points and sends them to Valhalla's trace_attributes endpoint:

const response = await fetch(`${VALHALLA_URL}/trace_attributes`, {
  method: 'POST',
  body: JSON.stringify({
    shape: gpsBuffer.map(p => ({ lat: p.lat, lon: p.lng })),
    costing: 'auto',
    shape_match: 'map_snap',
    filters: {
      attributes: ['edge.way_id', 'edge.names', 'matched.point', 'matched.edge_index'],
      action: 'include'
    }
  })
});

Valhalla returns coordinates snapped to the actual road network, along with the road name and edge identifier. This snapped position is what we display on the map.

To avoid excessive API calls, we implemented a grid-based cache with 20-meter cells and 5-minute expiration, matching the Android implementation exactly.

Layer 3: Position Interpolation

Even with map matching, updates arrive every 1-2 seconds. At 60 km/h, that means the marker jumps 16-33 meters between updates. This looks terrible.

The solution is time-based interpolation. When a new GPS fix arrives, we record the current position as the start point and the new position as the target. On every animation frame (60fps via requestAnimationFrame), we calculate where the vehicle should be based on elapsed time:

getInterpolatedLocation(): InterpolatedLocation | null {
  if (!this.startLocation || !this.targetLocation) return null;

  const elapsed = Date.now() - this.lastUpdateTime;
  const t = Math.min(elapsed / this.gpsInterval, 1);

  const lat = this.startLocation.lat +
    (this.targetLocation.lat - this.startLocation.lat) * t;
  const lng = this.startLocation.lng +
    (this.targetLocation.lng - this.startLocation.lng) * t;

  return { lat, lng, bearing: this.interpolateBearing(t) };
}

The gpsInterval is calculated dynamically using an exponential moving average of actual GPS update intervals. This adapts to varying GPS performance across devices.

Layer 4: Bearing Interpolation

Bearing (the direction the vehicle is facing) requires special handling because it wraps around at 360 degrees. A naive interpolation from 350 degrees to 10 degrees would rotate the wrong way through 180 degrees.

private interpolateBearing(t: number): number {
  let startB = ((this.startLocation.bearing % 360) + 360) % 360;
  let targetB = ((this.targetLocation.bearing % 360) + 360) % 360;

  let diff = targetB - startB;
  if (diff > 180) diff -= 360;
  if (diff < -180) diff += 360;

  return ((startB + diff * t) + 360) % 360;
}

This ensures the bearing always interpolates along the shortest arc.


Camera Following System

Navigation apps need the map camera to follow the user while providing visibility of the road ahead. MapLibre GL JS provides the building blocks, but we had to construct the behavior ourselves.

Three Camera Modes

The application maintains three distinct camera modes:

const CAMERA_CONFIG = {
  freeDrive: {
    zoom: 16,
    pitch: 45,
    duration: 1000,
  },
  navigation: {
    zoom: 17,
    pitch: 60,
    duration: 800,
  },
  free: {
    duration: 300,
  },
};

Follow mode (freeDrive) centers the map on the user with a moderate pitch for 3D perspective. This is the default when not navigating.

Navigation mode increases the zoom and pitch, positioning the camera to show more of the road ahead. The user's position marker sits in the lower third of the screen rather than the center.

Free mode activates when the user manually pans or zooms the map. Camera following stops until the user taps the recenter button.

Camera Offset Calculation

In navigation mode, centering the camera directly on the user would waste half the screen showing road already traveled. Instead, we offset the camera ahead of the user based on their bearing:

const getCameraCenter = (
  lng: number,
  lat: number,
  bearing: number,
  mode: CameraMode
): [number, number] => {
  if (mode !== 'navigation') {
    return [lng, lat];
  }

  const zoom = map.getZoom();
  const offsetMeters = 150 * Math.pow(2, 15 - zoom);

  const bearingRad = (bearing * Math.PI) / 180;
  const latOffset = (offsetMeters / 111320) * Math.cos(bearingRad);
  const lngOffset = (offsetMeters / (111320 * Math.cos(lat * Math.PI / 180))) * Math.sin(bearingRad);

  return [lng + lngOffset, lat + latOffset];
};

The offset distance scales inversely with zoom level, zoomed out views need larger offsets to maintain the same visual positioning.

User Interaction Detection

When the user touches the map, we need to immediately stop camera following to avoid fighting their input. We listen to multiple events:

map.on('dragstart', () => { isUserInteracting = true; });
map.on('dragend', () => {
  isUserInteracting = false;
  setCameraMode('free');
  setShowCenterButton(true);
});
map.on('touchstart', () => { isUserInteracting = true; });
map.on('touchend', () => { isUserInteracting = false; });

The center button appears in the bottom-right corner, allowing the user to re-engage camera following with a single tap.


The Location Puck

The user's position is represented by a custom marker we call the puck, a blue arrow indicating position and direction. Unlike native apps that can use hardware-accelerated location indicators, we render this as an HTML element positioned by MapLibre.

const el = document.createElement('div');
el.className = 'user-puck';
el.innerHTML = '<img class="user-puck-image" src="/icons/puck.svg" />';

const marker = new maplibregl.Marker({
  element: el,
  rotationAlignment: 'map',
  pitchAlignment: 'map',
  subpixelPositioning: true,
})
  .setLngLat([location.lng, location.lat])
  .addTo(map);

The subpixelPositioning: true option is critical. Without it, MapLibre rounds marker positions to whole pixels, causing visible stepping during smooth interpolation. Subpixel positioning allows the marker to move in fractions of pixels, matching the interpolation precision.

Bearing updates are applied via CSS transform:

function updateUserPuckBearing(element: HTMLElement, bearing: number): void {
  const img = element.querySelector('.user-puck-image');
  if (img) {
    img.style.transform = `rotate(${bearing}deg)`;
  }
}

The CSS includes a 0.1-second transition for smooth rotation between bearing updates.


Service Worker and Offline Capabilities

A navigation app that fails without internet is not useful. The PWA caches aggressively to maximize offline functionality.

Caching Strategy

We use Workbox through vite-plugin-pwa with tiered caching based on content type:

Vector map tiles (.pbf files) use CacheFirst with 30-day expiration and capacity for 5,000 entries. Vector tiles are small (typically 10-50KB) and form the foundation of offline map viewing.

{
  urlPattern: /\/tileserver\/data\/.*\.pbf$/,
  handler: 'CacheFirst',
  options: {
    cacheName: 'map-tiles-vector',
    expiration: {
      maxEntries: 5000,
      maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
    },
  },
}

Raster tiles (satellite imagery) use CacheFirst with 7-day expiration and 1,000 entry limit. These are larger files, so we cache fewer of them for less time.

Map fonts and glyphs cache for 90 days, these rarely change and are essential for label rendering.

Style JSON files use NetworkFirst with a 5-second timeout. We prefer fresh styles but fall back to cached versions when offline.

What Works Offline

With cached tiles, users can view previously visited map areas without internet. Settings persist in localStorage. The application shell loads instantly from cache.

What Requires Internet

Active navigation requires Valhalla for route calculation and rerouting. Speed camera and traffic data come from APIs that cannot be cached meaningfully, stale traffic data is worse than no data. Search and geocoding require Nominatim.

This is a pragmatic compromise. Full offline navigation would require downloading entire regions of routing data, hundreds of megabytes that most users would never need.


Alert and Safety Services

The Android app's safety features, speed camera alerts, police reports, average speed zone tracking, all needed porting to the PWA.

Proximity Alert System

The alertService monitors the user's position relative to known hazards. When a speed camera or police report comes within range, it triggers both audio and visual alerts.

Alert distance scales with speed:

function getAlertDistance(speedKmh: number): number {
  if (speedKmh >= 140) return 800;
  if (speedKmh >= 120) return 700;
  if (speedKmh >= 100) return 600;
  if (speedKmh >= 80) return 500;
  if (speedKmh >= 60) return 400;
  if (speedKmh >= 40) return 300;
  return 200;
}

At highway speeds, users need more warning time to react safely.

Road-Ahead Detection

Not every nearby hazard is relevant. A speed camera on a parallel road or in the opposite direction should not trigger an alert.

We use three strategies to determine if a hazard is actually ahead:

  1. Route matching: If navigating, check if the hazard is within 7 meters of the planned route
  2. Edge ID matching: Valhalla's map matching returns road segment identifiers; compare these with the hazard's location
  3. Bearing cone: Check if the hazard falls within a 54-degree cone (27 degrees either side) of the current heading

Average Speed Zone Tracking

Average speed zones (where cameras measure your average speed over a distance rather than instantaneous speed) require continuous tracking from entry to exit.

The avgSpeedZoneService implements a scoring system to match the correct zone when multiple zones exist nearby:

const score =
  bearingScore * 0.40 +      // Alignment with zone direction
  entryPointScore * 0.25 +   // Position along zone route
  distanceScore * 0.20 +     // Route distance matching
  separationScore * 0.15;    // Distinctness from other zones

Once inside a zone, we track distance traveled and elapsed time to calculate current average speed. The UI shows whether you are on track to exceed the limit at the exit camera.


Text-to-Speech Navigation

Turn-by-turn voice guidance uses the Web Speech API. Each maneuver triggers two announcements:

  1. Advance warning at 400 meters: "In 400 meters, turn right onto Main Street"
  2. Confirmation at 100 meters: "Turn right now"

We maintain announcement state per route to prevent duplicate notifications when GPS updates rapidly:

interface AnnouncementState {
  routeId: string;
  announced400m: Set<number>;  // Maneuver indices
  announced100m: Set<number>;
}

Voice selection adapts to the user's language setting. Bulgarian uses different pitch and rate parameters than English for natural-sounding speech:

const ttsConfig = {
  en: { rate: 0.8, pitch: 1.1 },
  bg: { rate: 1.05, pitch: 0.8 },
};

iOS-Specific Considerations

Safari on iOS has PWA limitations that required workarounds:

Installation

iOS does not show install prompts for PWAs. Users must manually tap Share, then "Add to Home Screen". We detect iOS and show custom installation instructions in the UI.

Wake Lock

Screen dimming during navigation is problematic. The Screen Wake Lock API keeps the display active:

if ('wakeLock' in navigator) {
  wakeLock = await navigator.wakeLock.request('screen');
}

Safari supports this as of iOS 16.4.

Safe Areas

The notch and home indicator on modern iPhones require CSS safe area handling:

@supports (padding-bottom: env(safe-area-inset-bottom)) {
  .speed-bar {
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
  }
}

Splash Screens

iOS PWAs display a splash screen during launch. We provide seven different resolutions to cover iPhone variants from SE to Pro Max:

<link rel="apple-touch-startup-image"
      href="/splash-1284x2778.png"
      media="(device-width: 428px) and (device-height: 926px)">

Performance Results

The PWA achieves acceptable performance for a navigation application:

  • Initial load: 3-5 seconds (cached: under 1 second)
  • GPS update processing: under 5ms per update
  • Map rendering: 60fps on modern devices
  • Memory usage: 150-300MB depending on cached tile count

Bundle size after tree-shaking is approximately 4MB, with map tiles loaded on demand.


What is Next

The PWA is feature-complete with the Android version. Users get:

  • Real-time navigation with turn-by-turn voice guidance
  • Speed camera and police alerts
  • Waze community reports
  • Average speed zone tracking
  • Speed limit display
  • Offline map viewing for cached areas

Future work includes:

  • Background location tracking (currently requires the app to remain in foreground)
  • Tile pre-caching for offline route planning
  • Native app packaging via Capacitor if App Store economics improve

For now, iOS users can install BlitzWay from Safari by visiting the app URL and adding it to their home screen. No App Store required, no annual fees, no gatekeepers.

The web platform has matured enough to support real navigation applications. BlitzWay proves it.


BlitzWay is available at blitzwaypwa.ra-development.com. The Android app is on Google Play. Both are free.

Tags: No tags