Skip to main content

Overview

Rodando uses Mapbox GL JS for all map interactions, including:
  • Interactive base maps with geolocation
  • Route visualization with polylines
  • Draggable markers for origin and destination
  • Geocoding (forward and reverse)
  • Real-time location tracking

Mapbox Services

MapboxDirectionsService

Calculates driving routes between two points using the Mapbox Directions API. Location: src/app/core/services/http/mapbox-directions.service.ts

getRoute Method

getRoute
(origin: LatLng, destination: LatLng) => Observable<RouteResult>
Fetches an optimized driving route with distance and duration.Parameters:
interface LatLng {
  lat: number;  // Latitude
  lng: number;  // Longitude
}
Returns:
interface RouteResult {
  distanceKm: number;       // e.g., 12.3
  durationMin: number;      // e.g., 24
  feature: FeatureCollection<LineString>;  // GeoJSON for rendering
  raw?: any;                // Full Mapbox response
}
Example:
const origin = { lat: 20.0236, lng: -75.8286 };
const dest = { lat: 20.0400, lng: -75.8100 };

this.directionsService.getRoute(origin, dest).subscribe(route => {
  console.log(`Distance: ${route.distanceKm} km`);
  console.log(`Duration: ${route.durationMin} min`);
  
  // Draw route on map
  map.addSource('route', {
    type: 'geojson',
    data: route.feature
  });
  
  map.addLayer({
    id: 'route-line',
    type: 'line',
    source: 'route',
    paint: {
      'line-width': 5,
      'line-color': '#FF3B30'
    }
  });
});
Routes use the driving profile and include traffic data when available. The geometry is returned as GeoJSON for easy rendering.

MapboxPlacesService

Provides geocoding (address search) and reverse geocoding (coordinates to address). Location: src/app/core/services/http/mapbox-places.service.ts

search Method

Searches for places matching a text query.Options:
interface SearchOpts {
  proximity?: LatLng;        // Bias results near this point
  bbox?: BBox;               // Restrict to bounding box
  country?: string;          // ISO country code (e.g., 'cu')
  limit?: number;            // Max results (default: 10)
  types?: string;            // Feature types (see below)
  language?: string;         // Result language (e.g., 'es')
  clampToSantiagoProvince?: boolean;  // Filter to Santiago de Cuba
}

type BBox = [minLng, minLat, maxLng, maxLat];
Valid types:
  • poi - Points of interest
  • poi.landmark - Landmarks
  • address - Street addresses
  • place - Cities and towns
  • locality - Neighborhoods
  • neighborhood - Sub-neighborhoods
Example:
this.placesService.search('Parque Céspedes', {
  proximity: { lat: 20.0236, lng: -75.8286 },
  clampToSantiagoProvince: true,
  language: 'es',
  limit: 5
}).subscribe(results => {
  results.forEach(place => {
    console.log(place.text);       // "Parque Céspedes"
    console.log(place.placeName);  // "Parque Céspedes, Santiago de Cuba"
    console.log(place.coords);     // { lat: 20.0244, lng: -75.8269 }
  });
});

reverse Method

reverse
(lng: number, lat: number, opts?) => Observable<ReverseResult | null>
Converts coordinates to a human-readable address.Options:
interface ReverseOpts {
  clampToSantiagoProvince?: boolean;
  language?: string;
}
Returns:
interface ReverseResult {
  label: string;              // "Calle Enramadas, Santiago de Cuba"
  coords: { lat: number; lng: number };
}
Example:
this.placesService.reverse(-75.8269, 20.0244).subscribe(result => {
  if (result) {
    console.log(result.label);  // "Parque Céspedes, Centro Histórico"
  }
});
The service automatically strips redundant location suffixes like ”, Santiago de Cuba, Cuba” for cleaner UI labels.

Map Component Integration

Here’s how the MapComponent integrates Mapbox GL JS:

Initialization

src/app/features/tabs/map/map.component.ts
import mapboxgl from 'mapbox-gl';
import { environment } from '@/environments/environment';

export class MapComponent implements AfterViewInit {
  @ViewChild('mapEl', { static: true }) mapEl!: ElementRef<HTMLDivElement>;
  map!: mapboxgl.Map;
  geolocate!: mapboxgl.GeolocateControl;
  
  ngAfterViewInit() {
    this.initMap();
  }
  
  private initMap() {
    mapboxgl.accessToken = environment.mapbox.accessToken;
    
    this.map = new mapboxgl.Map({
      container: this.mapEl.nativeElement,
      style: 'mapbox://styles/mapbox/streets-v12',
      center: [-75.83, 20.02],  // Santiago de Cuba
      zoom: 12,
      attributionControl: true,
      dragPan: true,
      scrollZoom: true,
      doubleClickZoom: true
    });
    
    // Add controls
    this.map.addControl(
      new mapboxgl.ScaleControl({ maxWidth: 100, unit: 'metric' })
    );
    
    this.geolocate = new mapboxgl.GeolocateControl({
      positionOptions: { enableHighAccuracy: true },
      trackUserLocation: true,
      showUserHeading: true
    });
    this.map.addControl(this.geolocate, 'top-left');
    
    // Wait for map to load
    this.map.on('load', () => {
      this.isLoaded.set(true);
      this.map.resize();
    });
  }
}

Adding Markers

private originMarker?: mapboxgl.Marker;
private destMarker?: mapboxgl.Marker;

// Add origin marker (blue, non-draggable)
private placeOrMoveOrigin(point: LatLng, title = 'Tu ubicación') {
  if (!this.originMarker) {
    this.originMarker = new mapboxgl.Marker({ color: '#0A84FF' })
      .setLngLat([point.lng, point.lat])
      .setPopup(new mapboxgl.Popup({ offset: 10 }).setText(title))
      .addTo(this.map);
  } else {
    this.originMarker.setLngLat([point.lng, point.lat]);
  }
}

// Add destination marker (red, draggable)
private ensureDestPicker(lng: number, lat: number, title = 'Destino') {
  if (!this.destMarker) {
    this.destMarker = new mapboxgl.Marker({ 
      color: '#FF3B30', 
      draggable: true 
    })
      .setLngLat([lng, lat])
      .setPopup(new mapboxgl.Popup({ offset: 10 }).setText(title))
      .addTo(this.map);
    
    // Handle drag events
    this.destMarker.on('dragend', () => {
      const ll = this.destMarker!.getLngLat();
      this.facade.recalcRouteAfterAdjust({ lat: ll.lat, lng: ll.lng });
    });
  } else {
    this.destMarker.setLngLat([lng, lat]);
  }
}

Drawing Routes

// Effect: Draw route when routeSummary changes
effect(() => {
  if (!this.isLoaded()) return;
  
  const rs = this.store.routeSummary();
  const srcId = 'trip-route';
  const layerId = 'trip-route-line';
  
  // Clear previous route
  if (!rs?.geometry) {
    if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
    if (this.map.getSource(srcId)) this.map.removeSource(srcId);
    return;
  }
  
  // Add new route
  if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
  if (this.map.getSource(srcId)) this.map.removeSource(srcId);
  
  this.map.addSource(srcId, {
    type: 'geojson',
    data: rs.geometry
  });
  
  this.map.addLayer({
    id: layerId,
    type: 'line',
    source: srcId,
    layout: {
      'line-join': 'round',
      'line-cap': 'round'
    },
    paint: {
      'line-width': 5,
      'line-color': '#FF3B30'
    }
  });
  
  // Fit map to route bounds
  const coords = rs.geometry.features[0].geometry.coordinates;
  const bounds = coords.reduce(
    (b, c) => b.extend(c as [number, number]),
    new mapboxgl.LngLatBounds(coords[0], coords[0])
  );
  
  this.map.fitBounds(bounds, { padding: 60, duration: 600 });
});

Handling User Interactions

// Click on map to set destination
this.map.on('click', (e) => {
  const { lng, lat } = e.lngLat;
  this.ensureDestPicker(lng, lat);
  this.facade.recalcRouteAfterAdjust({ lat, lng });
});

// Center on user location
centerOnMe() {
  const o = this.store.originPoint();
  if (!o) return;
  
  this.map.easeTo({
    center: [o.lng, o.lat],
    zoom: 15,
    duration: 600
  });
}

Geolocation

The app uses mapboxgl.GeolocateControl for user location:
this.geolocate = new mapboxgl.GeolocateControl({
  positionOptions: {
    enableHighAccuracy: true,
    timeout: 10000,
    maximumAge: 10000
  },
  trackUserLocation: true,   // Continuous tracking
  showUserHeading: true      // Show compass direction
});

this.map.addControl(this.geolocate, 'top-left');

// Listen for location updates
this.geolocate.on('geolocate', (e: GeolocationPosition) => {
  const { longitude, latitude, accuracy } = e.coords;
  console.log(`Location: ${latitude}, ${longitude}`);
  console.log(`Accuracy: ${accuracy}m`);
});

// Handle errors
this.geolocate.on('error', (err) => {
  console.error('Geolocation error:', err);
});
On some Android devices, the geolocation provider may return 403 errors. Always handle these gracefully and provide manual location selection as a fallback.

Location Provider

For programmatic location access (without UI), use LocationProvider:
import { LocationProvider } from '@/app/core/providers/location.provider';

export class MyComponent {
  private loc = inject(LocationProvider);
  
  async getCurrentLocation() {
    try {
      // One-time location
      const sample: GeoSample = await this.loc.getOnceBalanced();
      console.log(sample.lat, sample.lng, sample.accuracy);
    } catch (err) {
      console.error('Failed to get location:', err);
    }
  }
  
  watchLocation() {
    // Continuous tracking
    this.loc.watchBalanced()
      .pipe(sampleTime(4000))  // Throttle to every 4s
      .subscribe(sample => {
        console.log('Updated location:', sample);
      });
  }
}
GeoSample Interface:
interface GeoSample {
  lat: number;
  lng: number;
  accuracy?: number;     // Meters
  altitude?: number;     // Meters
  heading?: number;      // Degrees (0-360)
  speed?: number;        // m/s
  timestamp: number;     // Epoch milliseconds
}

Santiago de Cuba Clamping

The app restricts searches to Santiago de Cuba province by default:
private SCU_PROV_BBOX: BBox = [-76.30, 19.60, -75.10, 20.60];
private SCU_PROV_CENTER: LatLng = { lng: -75.82, lat: 20.02 };

private isInSantiagoProvince(f: MbFeature): boolean {
  const ctx = f.context ?? [];
  const tail = f.place_name.toLowerCase();
  const texts = ctx.map(c => c.text.toLowerCase());
  
  return tail.includes('santiago de cuba') ||
         texts.some(t => t.includes('santiago de cuba'));
}

// Usage
this.placesService.search('Parque', {
  clampToSantiagoProvince: true  // Default: true
}).subscribe(results => {
  // Only results in Santiago de Cuba province
});
Set clampToSantiagoProvince: false if you need to search outside the province (e.g., for airport or inter-city trips).

Map Styling

The app uses mapbox://styles/mapbox/streets-v12 by default. To customize:
this.map = new mapboxgl.Map({
  container: this.mapEl.nativeElement,
  style: 'mapbox://styles/your-username/your-style-id',
  // ... other options
});
Available Mapbox styles:
  • streets-v12 - Standard street map
  • outdoors-v12 - Topographic style
  • light-v11 - Light monochrome
  • dark-v11 - Dark monochrome
  • satellite-v9 - Satellite imagery
  • satellite-streets-v12 - Satellite + labels

Best Practices

Always resize map after layout changes

Call map.resize() after showing/hiding modals or changing container size:
setTimeout(() => this.map.resize(), 0);

Clean up on destroy

Remove the map instance to prevent memory leaks:
ngOnDestroy() {
  try {
    if (this.map) this.map.remove();
  } catch {}
}

Validate coordinates

Always validate lat/lng before passing to Mapbox:
const isValid = (n: any) => typeof n === 'number' && isFinite(n);
if (!isValid(lat) || !isValid(lng)) {
  throw new Error('Invalid coordinates');
}
Mapbox GL JS requires mapboxgl.accessToken to be set before creating a map. Store the token in environment.ts.

Build docs developers (and LLMs) love