import * as MapboxGL from 'mapbox-gl' import GA from 'react-ga4' import 'mapbox-gl/dist/mapbox-gl.css' import config from './../config' import { NBMapOptions } from './options' import { buildStyle, getSymbolLayers } from './style-ctl' import { DirectionsResponse } from './../api/directions' import { decodePolyline } from './decoder' import Marker from './marker' import Polyline from './polyline' import palettes from './palettes' import { SnapToRoadsResponse } from '../api/snap-to-roads' import { Position } from './geo' GA.initialize('G-WJP85E58K8') export default class Map { private readonly _map: MapboxGL.Map private readonly _options: NBMapOptions private _trafficLayer: MapboxGL.Layer | null private _prefillLayer: MapboxGL.Layer | null private _symbolLayers: Array constructor(el: HTMLElement, options: NBMapOptions) { if (!config.API_KEY) { const msg = 'An API Key is required to create a map!' alert(msg) throw new Error( msg + ' For example, add this line of code before creating a new map instance: nextbillion.setApiKey("your_api_key")', ) } GA.event({ category: 'web_sdk', action: 'map_instance_created', }) this._options = Object.assign({}, options) const defaultOptions: MapboxGL.MapboxOptions = { container: el || options.container, style: options.style || buildStyle(options.vectorTilesSourceUrl, options.theme), zoom: 12, center: [77.2223, 28.5934], attributionControl: false, hash: false, transformRequest, } const mapboxOptions = Object.assign({}, defaultOptions, options) this._map = new MapboxGL.Map(mapboxOptions) const attrib = new MapboxGL.AttributionControl({ customAttribution: '© NextBillion.ai © MapTiler © OpenStreetMap contributors', }) this._map.addControl(attrib) this._map.on('load', () => { this._addSymbolLayers() }) this._trafficLayer = null this._prefillLayer = null this._symbolLayers = Array() } _addSymbolLayers() { this._symbolLayers = getSymbolLayers() this._symbolLayers.forEach((layer) => this._map.addLayer(layer)) } _removeSymbolLayers() { this._symbolLayers.forEach((layer) => this._map.removeLayer(layer.id)) } get map(): MapboxGL.Map { return this._map } setTheme(theme: string) { const newStyle = buildStyle(this._options.vectorTilesSourceUrl, theme) this._map.setStyle(newStyle) this._options.theme = theme } setSource(url: string) { const newStyle = buildStyle(url, this._options.theme) this._map.setStyle(newStyle) this._options.vectorTilesSourceUrl = url } togglePrefill() { if (this._prefillLayer !== null) { // Turning off existing prefill vis this._map.removeLayer('prefill-data-layer') this._prefillLayer = null return } this._removeSymbolLayers() if (!this._map.getSource('prefill-data')) { this._map.addSource('prefill-data', { type: 'vector', url: 'https://speedvt.inrix-dev.nextbillion.io/public.prematched_osm_node_pairs.json', // url: 'http://localhost:3000/public.prematched_osm_node_pairs.json', }) } const vectorLayer = { id: 'prefill-data-layer', source: 'prefill-data', 'source-layer': 'public.prematched_osm_node_pairs', type: 'line', paint: { 'line-width': 4, 'line-color': '#092457', }, } this._map.addLayer(vectorLayer as MapboxGL.Layer) this._prefillLayer = vectorLayer as MapboxGL.Layer this._addSymbolLayers() } toggleTraffic() { if (this._trafficLayer !== null) { // Turning off existing traffic vis this._map.removeLayer('live-traffic-layer') this._trafficLayer = null return } this._removeSymbolLayers() if (!this._map.getSource('live-traffic-speed')) { this._map.addSource('live-traffic-speed', { type: 'vector', url: 'https://speedvt.inrix-dev.nextbillion.io/public.segment_speeds.json', // url: 'http://localhost:3000/public.segment_speeds.json', }) } const vectorLayerTraffic = { id: 'live-traffic-layer', source: 'live-traffic-speed', 'source-layer': 'public.segment_speeds', type: 'line', paint: { 'line-width': 4, 'line-color': [ 'interpolate', ['linear'], ['number', ['get', 'speed_bucket'], 3], 0, 'purple', 1, 'red', 2, 'orange', 3, '#4db846', ], }, } this._map.addLayer(vectorLayerTraffic as MapboxGL.Layer) this._trafficLayer = vectorLayerTraffic as MapboxGL.Layer this._addSymbolLayers() } renderDirections(data: DirectionsResponse): ((Marker | Polyline)[] | undefined)[] { if (!Array.isArray(data.routes)) { console.error('no valid routes to render') return [] } // loop through all routes let i = 0 const shapes = data.routes.map((route) => { // take a color for the polyline const color = palettes[i] i += 1 if (i > palettes.length) { i = 0 } const coords = decodePolyline(route.geometry, 5) if (coords.length < 2) { console.warn('Too few points, ignore this route') return } const start = coords[0] const end = coords[coords.length - 1] const polyline = new Polyline({ path: [coords.map((item) => ({ lat: item[0], lng: item[1] }))], strokeColor: color, strokeWeight: 5, map: this, }) const startMarker = new Marker({ position: { lat: start[0], lng: start[1] }, icon: 'red', map: this, }) const endMarker = new Marker({ position: { lat: end[0], lng: end[1] }, icon: 'green', map: this, }) this._map.fitBounds( [ { lat: start[0], lng: start[1] }, { lat: end[0], lng: end[1] }, ], { padding: 100 }, ) return [startMarker, endMarker, polyline] }) return shapes } // renderSnapToRoads is a helper function to draw snap-to-roads // results on the map. // If "original" is properly set, an extra polyline for reference // will be drawn on the map too. renderSnapToRoads(data: SnapToRoadsResponse, original?: Position[]): Polyline[] | null { if (!Array.isArray(data.snappedPoints) || data.snappedPoints.length === 0) { console.error('no valid points to render') return null } const result = [] if (Array.isArray(original) && original.length > 0) { const referencePolyline = new Polyline({ map: this, strokeColor: '#c3c6c6', strokeWeight: 5, path: [original], }) result.push(referencePolyline) } const polyline = new Polyline({ map: this, strokeColor: '#f1778d', strokeWeight: 5, path: [data.snappedPoints.map((p) => ({ lat: p.location.latitude, lng: p.location.longitude }))], }) result.push(polyline) const start = data.snappedPoints[0].location const end = data.snappedPoints[data.snappedPoints.length - 1].location this._map.fitBounds( [ { lat: start.latitude, lng: start.longitude }, { lat: end.latitude, lng: end.longitude }, ], { padding: 100 }, ) return result } removeDirections(directionShapes: ((Marker | Polyline)[] | undefined)[]) { directionShapes.forEach((shapeRow) => { if (!Array.isArray(shapeRow)) { return } shapeRow.forEach((shape) => shape.remove()) }) } removeSnapToRoads(snapToRoadsShapes: Polyline[]) { if (!Array.isArray(snapToRoadsShapes)) { return } snapToRoadsShapes.forEach((shape) => shape.remove()) } flyTo(opts: MapboxGL.FlyToOptions) { this._map.flyTo(opts) } on(event: string, func: any) { this._map.on(event, func) } } function transformRequest(url: string, resourceType: MapboxGL.ResourceType): MapboxGL.RequestParameters { let key = config.API_KEY if ( resourceType === 'SpriteJSON' || resourceType === 'SpriteImage' || resourceType === 'Tile' || resourceType === 'Source' || resourceType === 'Style' ) { return { url: urlAddOrReplaceParam(url, { key: key }), } } return { url } } function urlAddOrReplaceParam(baseUrl: string, params: any): string { const url = new URL(baseUrl) for (const key in params) { url.searchParams.set(key, params[key]) } return url.toString() }