@maas-mr/client 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +483 -0
- package/dist/index.d.ts +196 -0
- package/dist/index.js +481 -0
- package/package.json +34 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @maas/client — Map-as-a-Service SDK
|
|
3
|
+
*
|
|
4
|
+
* Arabic maps, routing, and navigation for Mauritania.
|
|
5
|
+
* Works in the browser (with MapLibre GL JS) and in React Native / Node for API calls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Polyline6 decoder (Google/OSRM format, precision 1e-6) ────────────────────
|
|
9
|
+
|
|
10
|
+
function _decodePolyline6(encoded) {
|
|
11
|
+
const coords = [];
|
|
12
|
+
let index = 0, lat = 0, lng = 0;
|
|
13
|
+
while (index < encoded.length) {
|
|
14
|
+
let b, shift = 0, result = 0;
|
|
15
|
+
do {
|
|
16
|
+
b = encoded.charCodeAt(index++) - 63;
|
|
17
|
+
result |= (b & 0x1f) << shift;
|
|
18
|
+
shift += 5;
|
|
19
|
+
} while (b >= 0x20);
|
|
20
|
+
lat += result & 1 ? ~(result >> 1) : result >> 1;
|
|
21
|
+
|
|
22
|
+
shift = 0; result = 0;
|
|
23
|
+
do {
|
|
24
|
+
b = encoded.charCodeAt(index++) - 63;
|
|
25
|
+
result |= (b & 0x1f) << shift;
|
|
26
|
+
shift += 5;
|
|
27
|
+
} while (b >= 0x20);
|
|
28
|
+
lng += result & 1 ? ~(result >> 1) : result >> 1;
|
|
29
|
+
|
|
30
|
+
coords.push([lng / 1e6, lat / 1e6]);
|
|
31
|
+
}
|
|
32
|
+
return coords;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── MaaSClient ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
class MaaSClient {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {string} options.baseUrl Base URL of your MaaS API (no trailing slash)
|
|
41
|
+
* @param {string} options.apiKey Your API key (X-API-Key header)
|
|
42
|
+
*/
|
|
43
|
+
constructor({ baseUrl, apiKey }) {
|
|
44
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
45
|
+
this.apiKey = apiKey;
|
|
46
|
+
this._map = null;
|
|
47
|
+
this._driverMarker = null;
|
|
48
|
+
this._routeLayerAdded = false;
|
|
49
|
+
this._navSession = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Internal HTTP helpers ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async _get(path, params = {}) {
|
|
55
|
+
const url = new URL(this.baseUrl + path);
|
|
56
|
+
Object.entries(params).forEach(([k, v]) => {
|
|
57
|
+
if (v !== null && v !== undefined) url.searchParams.set(k, v);
|
|
58
|
+
});
|
|
59
|
+
const resp = await fetch(url.toString(), {
|
|
60
|
+
headers: { 'X-API-Key': this.apiKey },
|
|
61
|
+
});
|
|
62
|
+
if (!resp.ok) {
|
|
63
|
+
const err = await resp.json().catch(() => ({}));
|
|
64
|
+
throw new Error(`MaaS API error ${resp.status}: ${err.detail || err.message || resp.statusText}`);
|
|
65
|
+
}
|
|
66
|
+
return resp.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _post(path, body) {
|
|
70
|
+
const resp = await fetch(this.baseUrl + path, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify(body),
|
|
74
|
+
});
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
const err = await resp.json().catch(() => ({}));
|
|
77
|
+
throw new Error(`MaaS API error ${resp.status}: ${err.detail || err.message || resp.statusText}`);
|
|
78
|
+
}
|
|
79
|
+
return resp.json();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Places ─────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fuzzy-search places by Hassaniya Arabic name or variant spellings.
|
|
86
|
+
* @param {string} query
|
|
87
|
+
* @param {{ lat?: number, lon?: number, radiusKm?: number, limit?: number }} opts
|
|
88
|
+
* @returns {Promise<{ places: PlaceResult[], total: number }>}
|
|
89
|
+
*/
|
|
90
|
+
searchPlaces(query, opts = {}) {
|
|
91
|
+
return this._get('/v1/places/search', {
|
|
92
|
+
q: query,
|
|
93
|
+
lat: opts.lat,
|
|
94
|
+
lon: opts.lon,
|
|
95
|
+
radius_km: opts.radiusKm,
|
|
96
|
+
limit: opts.limit ?? 10,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get a single place by ID.
|
|
102
|
+
* @param {string} placeId
|
|
103
|
+
* @returns {Promise<PlaceResult>}
|
|
104
|
+
*/
|
|
105
|
+
getPlace(placeId) {
|
|
106
|
+
return this._get(`/v1/places/${encodeURIComponent(placeId)}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Geocoding ──────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert a place name to coordinates.
|
|
113
|
+
* @param {string} query
|
|
114
|
+
* @returns {Promise<ForwardGeocodeResult>}
|
|
115
|
+
*/
|
|
116
|
+
forwardGeocode(query) {
|
|
117
|
+
return this._get('/v1/geocode/forward', { q: query });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Convert coordinates to the nearest known place name.
|
|
122
|
+
* @param {number} lat
|
|
123
|
+
* @param {number} lon
|
|
124
|
+
* @param {number} [maxDistM=500]
|
|
125
|
+
* @returns {Promise<ReverseGeocodeResult>}
|
|
126
|
+
*/
|
|
127
|
+
reverseGeocode(lat, lon, maxDistM = 500) {
|
|
128
|
+
return this._get('/v1/geocode/reverse', { lat, lon, max_dist_m: maxDistM });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Routing ────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get turn-by-turn directions between two points.
|
|
135
|
+
* @param {number} fromLat
|
|
136
|
+
* @param {number} fromLon
|
|
137
|
+
* @param {number} toLat
|
|
138
|
+
* @param {number} toLon
|
|
139
|
+
* @param {'ar'|'en'} [language='ar']
|
|
140
|
+
* @returns {Promise<RouteResult>}
|
|
141
|
+
*/
|
|
142
|
+
getRoute(fromLat, fromLon, toLat, toLon, language = 'ar') {
|
|
143
|
+
return this._get('/v1/route', {
|
|
144
|
+
from_lat: fromLat, from_lon: fromLon,
|
|
145
|
+
to_lat: toLat, to_lon: toLon,
|
|
146
|
+
language,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get navigation progress for a live GPS position on an active route.
|
|
152
|
+
* Call this on every GPS fix during navigation.
|
|
153
|
+
* @param {string} routeId - From getRoute() response
|
|
154
|
+
* @param {number} lat
|
|
155
|
+
* @param {number} lon
|
|
156
|
+
* @returns {Promise<RouteProgress>}
|
|
157
|
+
*/
|
|
158
|
+
getRouteProgress(routeId, lat, lon) {
|
|
159
|
+
return this._get('/v1/route/progress', { route_id: routeId, lat, lon });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── ETA ────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get ETA between two points.
|
|
166
|
+
* @param {number} fromLat
|
|
167
|
+
* @param {number} fromLon
|
|
168
|
+
* @param {number} toLat
|
|
169
|
+
* @param {number} toLon
|
|
170
|
+
* @returns {Promise<EtaResult>}
|
|
171
|
+
*/
|
|
172
|
+
getEta(fromLat, fromLon, toLat, toLon) {
|
|
173
|
+
return this._get('/v1/eta', {
|
|
174
|
+
from_lat: fromLat, from_lon: fromLon,
|
|
175
|
+
to_lat: toLat, to_lon: toLon,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Compute a distance/duration matrix (max 10×10).
|
|
181
|
+
* Useful for dispatch: find nearest available driver.
|
|
182
|
+
* @param {Array<{lat: number, lon: number}>} origins
|
|
183
|
+
* @param {Array<{lat: number, lon: number}>} destinations
|
|
184
|
+
* @returns {Promise<EtaMatrixResult>}
|
|
185
|
+
*/
|
|
186
|
+
getEtaMatrix(origins, destinations) {
|
|
187
|
+
return this._post('/v1/eta/matrix', { origins, destinations });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Driver Location ────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Push current driver location (HTTP).
|
|
194
|
+
* @param {string} driverId
|
|
195
|
+
* @param {number} lat
|
|
196
|
+
* @param {number} lon
|
|
197
|
+
* @param {{ heading?: number, speedKmh?: number }} [opts]
|
|
198
|
+
*/
|
|
199
|
+
updateDriverLocation(driverId, lat, lon, opts = {}) {
|
|
200
|
+
return this._post(`/v1/drivers/${encodeURIComponent(driverId)}/location`, {
|
|
201
|
+
lat, lon,
|
|
202
|
+
heading: opts.heading,
|
|
203
|
+
speed_kmh: opts.speedKmh,
|
|
204
|
+
timestamp: new Date().toISOString(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get nearby active drivers.
|
|
210
|
+
* @param {number} lat
|
|
211
|
+
* @param {number} lon
|
|
212
|
+
* @param {number} [radiusKm=5]
|
|
213
|
+
* @returns {Promise<NearbyDriversResult>}
|
|
214
|
+
*/
|
|
215
|
+
getNearbyDrivers(lat, lon, radiusKm = 5) {
|
|
216
|
+
return this._get('/v1/drivers/nearby', { lat, lon, radius_km: radiusKm });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Real-time Tracking (WebSocket) ─────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Open a WebSocket to stream driver location updates. Call from the driver app.
|
|
223
|
+
* @param {string} tripId
|
|
224
|
+
* @param {string} driverId
|
|
225
|
+
* @returns {{ send: (lat: number, lon: number, opts?: object) => void, end: () => void, close: () => void }}
|
|
226
|
+
*/
|
|
227
|
+
trackDriver(tripId, driverId) {
|
|
228
|
+
const wsUrl = this.baseUrl.replace(/^http/, 'ws')
|
|
229
|
+
+ `/v1/tracking/${encodeURIComponent(tripId)}/driver`
|
|
230
|
+
+ `?api_key=${encodeURIComponent(this.apiKey)}&driver_id=${encodeURIComponent(driverId)}`;
|
|
231
|
+
|
|
232
|
+
const ws = new WebSocket(wsUrl);
|
|
233
|
+
ws.onopen = () => console.debug('[MaaS] Driver tracking connected');
|
|
234
|
+
ws.onerror = (e) => console.error('[MaaS] Driver WS error', e);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
send(lat, lon, opts = {}) {
|
|
238
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
239
|
+
ws.send(JSON.stringify({
|
|
240
|
+
type: 'location', lat, lon,
|
|
241
|
+
heading: opts.heading,
|
|
242
|
+
speed_kmh: opts.speedKmh,
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
end() {
|
|
248
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
249
|
+
ws.send(JSON.stringify({ type: 'trip_ended' }));
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
close() { ws.close(); },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Subscribe to driver location updates. Call from the passenger app.
|
|
258
|
+
* @param {string} tripId
|
|
259
|
+
* @param {(location: object) => void} onLocation
|
|
260
|
+
* @param {() => void} [onTripEnded]
|
|
261
|
+
* @returns {{ close: () => void }}
|
|
262
|
+
*/
|
|
263
|
+
trackPassenger(tripId, onLocation, onTripEnded = () => {}) {
|
|
264
|
+
const wsUrl = this.baseUrl.replace(/^http/, 'ws')
|
|
265
|
+
+ `/v1/tracking/${encodeURIComponent(tripId)}/passenger`
|
|
266
|
+
+ `?api_key=${encodeURIComponent(this.apiKey)}`;
|
|
267
|
+
|
|
268
|
+
const ws = new WebSocket(wsUrl);
|
|
269
|
+
ws.onmessage = (event) => {
|
|
270
|
+
try {
|
|
271
|
+
const data = JSON.parse(event.data);
|
|
272
|
+
if (data.type === 'driver_location') onLocation(data);
|
|
273
|
+
if (data.type === 'trip_ended') onTripEnded();
|
|
274
|
+
} catch { /* ignore malformed */ }
|
|
275
|
+
};
|
|
276
|
+
ws.onerror = (e) => console.error('[MaaS] Passenger WS error', e);
|
|
277
|
+
|
|
278
|
+
const ping = setInterval(() => {
|
|
279
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
|
|
280
|
+
}, 25000);
|
|
281
|
+
|
|
282
|
+
return { close() { clearInterval(ping); ws.close(); } };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Map (browser only) ─────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Initialize a MapLibre GL map centred on Nouakchott.
|
|
289
|
+
* Requires `maplibre-gl` to be installed/loaded.
|
|
290
|
+
* @param {string|HTMLElement} container DOM element id or element reference
|
|
291
|
+
* @param {{ center?: [number,number], zoom?: number, pitch?: number, bearing?: number, maxZoom?: number, style?: string }} [opts]
|
|
292
|
+
* @returns {import('maplibre-gl').Map}
|
|
293
|
+
*/
|
|
294
|
+
createMap(container, opts = {}) {
|
|
295
|
+
// Support both CDN global and npm import
|
|
296
|
+
const mgl = typeof maplibregl !== 'undefined' ? maplibregl : opts._maplibregl;
|
|
297
|
+
if (!mgl) throw new Error('maplibre-gl is not available. Pass it as opts._maplibregl or load the CDN script.');
|
|
298
|
+
|
|
299
|
+
this._maplibregl = mgl;
|
|
300
|
+
const map = new mgl.Map({
|
|
301
|
+
container,
|
|
302
|
+
style: opts.style ?? `${this.baseUrl}/tiles/styles/mauritania-ar/style.json`,
|
|
303
|
+
center: opts.center ?? [-15.9785, 18.0864],
|
|
304
|
+
zoom: opts.zoom ?? 13,
|
|
305
|
+
pitch: opts.pitch ?? 45,
|
|
306
|
+
bearing: opts.bearing ?? 0,
|
|
307
|
+
maxZoom: opts.maxZoom ?? 18,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
this._map = map;
|
|
311
|
+
return map;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Draw (or update) the route polyline on the map.
|
|
316
|
+
* @param {RouteResult} route Response from getRoute()
|
|
317
|
+
*/
|
|
318
|
+
showRoute(route) {
|
|
319
|
+
if (!this._map) throw new Error('Call createMap() first');
|
|
320
|
+
const coordinates = _decodePolyline6(route.geometry);
|
|
321
|
+
|
|
322
|
+
if (this._routeLayerAdded) {
|
|
323
|
+
this._map.getSource('maas-route').setData({
|
|
324
|
+
type: 'Feature', geometry: { type: 'LineString', coordinates },
|
|
325
|
+
});
|
|
326
|
+
} else {
|
|
327
|
+
this._map.addSource('maas-route', {
|
|
328
|
+
type: 'geojson',
|
|
329
|
+
data: { type: 'Feature', geometry: { type: 'LineString', coordinates } },
|
|
330
|
+
});
|
|
331
|
+
this._map.addLayer({
|
|
332
|
+
id: 'maas-route-line', type: 'line', source: 'maas-route',
|
|
333
|
+
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
334
|
+
paint: { 'line-color': '#2563eb', 'line-width': 5, 'line-opacity': 0.85 },
|
|
335
|
+
});
|
|
336
|
+
this._routeLayerAdded = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const lngs = coordinates.map(c => c[0]);
|
|
340
|
+
const lats = coordinates.map(c => c[1]);
|
|
341
|
+
this._map.fitBounds(
|
|
342
|
+
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
|
343
|
+
{ padding: 60 }
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Update (or create) the driver marker on the map.
|
|
349
|
+
* @param {number} lat
|
|
350
|
+
* @param {number} lon
|
|
351
|
+
* @param {number} [heading=0]
|
|
352
|
+
*/
|
|
353
|
+
updateDriverMarker(lat, lon, heading = 0) {
|
|
354
|
+
if (!this._map) throw new Error('Call createMap() first');
|
|
355
|
+
if (!this._driverMarker) {
|
|
356
|
+
const el = document.createElement('div');
|
|
357
|
+
el.style.cssText = 'width:36px;height:36px;background:#2563eb;border-radius:50%;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.3);cursor:pointer;';
|
|
358
|
+
this._driverMarker = new this._maplibregl.Marker({ element: el, rotation: heading })
|
|
359
|
+
.setLngLat([lon, lat]).addTo(this._map);
|
|
360
|
+
} else {
|
|
361
|
+
this._driverMarker.setLngLat([lon, lat]).setRotation(heading);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Navigation ─────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Start turn-by-turn navigation between two points.
|
|
369
|
+
*
|
|
370
|
+
* Handles everything internally:
|
|
371
|
+
* - Fetches route and draws it on the map
|
|
372
|
+
* - Watches device GPS and moves camera to follow
|
|
373
|
+
* - Polls /route/progress on every GPS fix
|
|
374
|
+
* - Auto re-routes when off-route
|
|
375
|
+
* - Fires callbacks so your UI can show instructions, ETA, etc.
|
|
376
|
+
*
|
|
377
|
+
* @param {number} fromLat
|
|
378
|
+
* @param {number} fromLon
|
|
379
|
+
* @param {number} toLat
|
|
380
|
+
* @param {number} toLon
|
|
381
|
+
* @param {{
|
|
382
|
+
* language?: 'ar'|'en',
|
|
383
|
+
* arrivalThresholdM?: number,
|
|
384
|
+
* onProgress?: (progress: RouteProgress) => void,
|
|
385
|
+
* onStep?: (step: RouteStep, index: number) => void,
|
|
386
|
+
* onOffRoute?: (distanceM: number) => void,
|
|
387
|
+
* onArrival?: () => void,
|
|
388
|
+
* onError?: (err: Error) => void,
|
|
389
|
+
* }} [opts]
|
|
390
|
+
* @returns {Promise<{ route: RouteResult, stop: () => void }>}
|
|
391
|
+
*/
|
|
392
|
+
async startNavigation(fromLat, fromLon, toLat, toLon, opts = {}) {
|
|
393
|
+
if (!this._map) throw new Error('Call createMap() first');
|
|
394
|
+
|
|
395
|
+
const {
|
|
396
|
+
language = 'ar',
|
|
397
|
+
arrivalThresholdM = 30,
|
|
398
|
+
onProgress = () => {},
|
|
399
|
+
onStep = () => {},
|
|
400
|
+
onOffRoute = () => {},
|
|
401
|
+
onArrival = () => {},
|
|
402
|
+
onError = () => {},
|
|
403
|
+
} = opts;
|
|
404
|
+
|
|
405
|
+
const route = await this.getRoute(fromLat, fromLon, toLat, toLon, language);
|
|
406
|
+
this.showRoute(route);
|
|
407
|
+
|
|
408
|
+
let routeId = route.route_id;
|
|
409
|
+
let currentStepIdx = -1;
|
|
410
|
+
let reroutePending = false;
|
|
411
|
+
let stopped = false;
|
|
412
|
+
let watchId = null;
|
|
413
|
+
|
|
414
|
+
const el = document.createElement('div');
|
|
415
|
+
el.style.cssText = 'width:22px;height:22px;border-radius:50%;background:#2563eb;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.4);';
|
|
416
|
+
const navMarker = new this._maplibregl.Marker({ element: el, rotationAlignment: 'map' })
|
|
417
|
+
.setLngLat([fromLon, fromLat]).addTo(this._map);
|
|
418
|
+
|
|
419
|
+
this._map.easeTo({ center: [fromLon, fromLat], zoom: 17, pitch: 60, bearing: 0, duration: 800 });
|
|
420
|
+
|
|
421
|
+
const handlePosition = async ({ coords }) => {
|
|
422
|
+
if (stopped) return;
|
|
423
|
+
const { latitude: lat, longitude: lon, heading } = coords;
|
|
424
|
+
|
|
425
|
+
navMarker.setLngLat([lon, lat]);
|
|
426
|
+
if (heading !== null) navMarker.setRotation(heading);
|
|
427
|
+
this._map.easeTo({ center: [lon, lat], bearing: heading ?? 0, duration: 400 });
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const progress = await this.getRouteProgress(routeId, lat, lon);
|
|
431
|
+
onProgress(progress);
|
|
432
|
+
|
|
433
|
+
if (progress.step_index !== currentStepIdx) {
|
|
434
|
+
currentStepIdx = progress.step_index;
|
|
435
|
+
onStep(progress.current_step, progress.step_index);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (progress.off_route && !reroutePending) {
|
|
439
|
+
reroutePending = true;
|
|
440
|
+
onOffRoute(progress.off_route_distance_m);
|
|
441
|
+
try {
|
|
442
|
+
const newRoute = await this.getRoute(lat, lon, toLat, toLon, language);
|
|
443
|
+
routeId = newRoute.route_id;
|
|
444
|
+
this.showRoute(newRoute);
|
|
445
|
+
currentStepIdx = -1;
|
|
446
|
+
} catch (e) { onError(e); }
|
|
447
|
+
finally { reroutePending = false; }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (progress.distance_remaining_m <= arrivalThresholdM) {
|
|
451
|
+
onArrival();
|
|
452
|
+
stop();
|
|
453
|
+
}
|
|
454
|
+
} catch (e) { onError(e); }
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
if (!navigator.geolocation) throw new Error('Geolocation not supported by this browser.');
|
|
458
|
+
watchId = navigator.geolocation.watchPosition(
|
|
459
|
+
handlePosition,
|
|
460
|
+
(e) => onError(new Error(`GPS: ${e.message}`)),
|
|
461
|
+
{ enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 },
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
function stop() {
|
|
465
|
+
if (stopped) return;
|
|
466
|
+
stopped = true;
|
|
467
|
+
if (watchId !== null) navigator.geolocation.clearWatch(watchId);
|
|
468
|
+
navMarker.remove();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this._navSession = { stop };
|
|
472
|
+
return { route, stop };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Stop an active navigation session. */
|
|
476
|
+
stopNavigation() {
|
|
477
|
+
if (this._navSession) { this._navSession.stop(); this._navSession = null; }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
module.exports = { MaaSClient };
|
|
483
|
+
module.exports.default = MaaSClient;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ── Shared types ───────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface PlaceResult {
|
|
4
|
+
id: string;
|
|
5
|
+
canonical_name: string;
|
|
6
|
+
variants: string[];
|
|
7
|
+
lat: number;
|
|
8
|
+
lon: number;
|
|
9
|
+
distance_m?: number;
|
|
10
|
+
score?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PlaceSearchResult {
|
|
14
|
+
places: PlaceResult[];
|
|
15
|
+
total: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ForwardGeocodeResult {
|
|
19
|
+
place_id: string;
|
|
20
|
+
canonical_name: string;
|
|
21
|
+
lat: number;
|
|
22
|
+
lon: number;
|
|
23
|
+
confidence: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReverseGeocodeResult {
|
|
27
|
+
place_id: string;
|
|
28
|
+
canonical_name: string;
|
|
29
|
+
lat: number;
|
|
30
|
+
lon: number;
|
|
31
|
+
distance_m: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RouteStep {
|
|
35
|
+
instruction: string;
|
|
36
|
+
distance_m: number;
|
|
37
|
+
duration_s: number;
|
|
38
|
+
maneuver_type: string;
|
|
39
|
+
street_name: string;
|
|
40
|
+
lat: number;
|
|
41
|
+
lon: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RouteWaypoint {
|
|
45
|
+
lat: number;
|
|
46
|
+
lon: number;
|
|
47
|
+
snapped_lat: number;
|
|
48
|
+
snapped_lon: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RouteResult {
|
|
52
|
+
route_id: string;
|
|
53
|
+
distance_m: number;
|
|
54
|
+
duration_s: number;
|
|
55
|
+
/** Encoded polyline (precision 1e-6) */
|
|
56
|
+
geometry: string;
|
|
57
|
+
steps: RouteStep[];
|
|
58
|
+
waypoints: RouteWaypoint[];
|
|
59
|
+
/** 'osrm' or 'estimated' (straight-line fallback) */
|
|
60
|
+
routing: 'osrm' | 'estimated';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RouteProgress {
|
|
64
|
+
step_index: number;
|
|
65
|
+
current_step: RouteStep;
|
|
66
|
+
next_step: RouteStep | null;
|
|
67
|
+
distance_to_next_m: number;
|
|
68
|
+
distance_remaining_m: number;
|
|
69
|
+
duration_remaining_s: number;
|
|
70
|
+
off_route: boolean;
|
|
71
|
+
off_route_distance_m: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EtaResult {
|
|
75
|
+
distance_m: number;
|
|
76
|
+
duration_s: number;
|
|
77
|
+
/** ISO 8601 timestamp */
|
|
78
|
+
eta_iso: string;
|
|
79
|
+
traffic_factor: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EtaMatrixResult {
|
|
83
|
+
durations_s: Array<Array<number | null>>;
|
|
84
|
+
distances_m: Array<Array<number | null>>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DriverLocation {
|
|
88
|
+
lat: number;
|
|
89
|
+
lon: number;
|
|
90
|
+
heading?: number;
|
|
91
|
+
speed_kmh?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DriverInfo extends DriverLocation {
|
|
95
|
+
driver_id: string;
|
|
96
|
+
distance_m: number;
|
|
97
|
+
last_seen?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface NearbyDriversResult {
|
|
101
|
+
drivers: DriverInfo[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DriverTracker {
|
|
105
|
+
send(lat: number, lon: number, opts?: { heading?: number; speedKmh?: number }): void;
|
|
106
|
+
end(): void;
|
|
107
|
+
close(): void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface PassengerTracker {
|
|
111
|
+
close(): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface NavigationSession {
|
|
115
|
+
route: RouteResult;
|
|
116
|
+
stop(): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── MaaSClient options ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export interface MaaSClientOptions {
|
|
122
|
+
baseUrl: string;
|
|
123
|
+
apiKey: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface SearchPlacesOptions {
|
|
127
|
+
lat?: number;
|
|
128
|
+
lon?: number;
|
|
129
|
+
radiusKm?: number;
|
|
130
|
+
limit?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface CreateMapOptions {
|
|
134
|
+
/** [lng, lat] — defaults to Nouakchott centre */
|
|
135
|
+
center?: [number, number];
|
|
136
|
+
zoom?: number;
|
|
137
|
+
pitch?: number;
|
|
138
|
+
bearing?: number;
|
|
139
|
+
maxZoom?: number;
|
|
140
|
+
/** Override the style URL */
|
|
141
|
+
style?: string;
|
|
142
|
+
/** Pass maplibre-gl module when not available as a global (npm usage) */
|
|
143
|
+
_maplibregl?: unknown;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface NavigationOptions {
|
|
147
|
+
language?: 'ar' | 'en';
|
|
148
|
+
/** Distance in metres at which arrival is triggered (default 30) */
|
|
149
|
+
arrivalThresholdM?: number;
|
|
150
|
+
onProgress?: (progress: RouteProgress) => void;
|
|
151
|
+
onStep?: (step: RouteStep, index: number) => void;
|
|
152
|
+
onOffRoute?: (distanceM: number) => void;
|
|
153
|
+
onArrival?: () => void;
|
|
154
|
+
onError?: (err: Error) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── MaaSClient ─────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export declare class MaaSClient {
|
|
160
|
+
constructor(options: MaaSClientOptions);
|
|
161
|
+
|
|
162
|
+
// Places
|
|
163
|
+
searchPlaces(query: string, opts?: SearchPlacesOptions): Promise<PlaceSearchResult>;
|
|
164
|
+
getPlace(placeId: string): Promise<PlaceResult>;
|
|
165
|
+
|
|
166
|
+
// Geocoding
|
|
167
|
+
forwardGeocode(query: string): Promise<ForwardGeocodeResult>;
|
|
168
|
+
reverseGeocode(lat: number, lon: number, maxDistM?: number): Promise<ReverseGeocodeResult>;
|
|
169
|
+
|
|
170
|
+
// Routing
|
|
171
|
+
getRoute(fromLat: number, fromLon: number, toLat: number, toLon: number, language?: 'ar' | 'en'): Promise<RouteResult>;
|
|
172
|
+
getRouteProgress(routeId: string, lat: number, lon: number): Promise<RouteProgress>;
|
|
173
|
+
|
|
174
|
+
// ETA
|
|
175
|
+
getEta(fromLat: number, fromLon: number, toLat: number, toLon: number): Promise<EtaResult>;
|
|
176
|
+
getEtaMatrix(origins: DriverLocation[], destinations: DriverLocation[]): Promise<EtaMatrixResult>;
|
|
177
|
+
|
|
178
|
+
// Drivers
|
|
179
|
+
updateDriverLocation(driverId: string, lat: number, lon: number, opts?: { heading?: number; speedKmh?: number }): Promise<void>;
|
|
180
|
+
getNearbyDrivers(lat: number, lon: number, radiusKm?: number): Promise<NearbyDriversResult>;
|
|
181
|
+
|
|
182
|
+
// Real-time tracking
|
|
183
|
+
trackDriver(tripId: string, driverId: string): DriverTracker;
|
|
184
|
+
trackPassenger(tripId: string, onLocation: (location: DriverLocation) => void, onTripEnded?: () => void): PassengerTracker;
|
|
185
|
+
|
|
186
|
+
// Map (browser only)
|
|
187
|
+
createMap(container: string | HTMLElement, opts?: CreateMapOptions): unknown;
|
|
188
|
+
showRoute(route: RouteResult): void;
|
|
189
|
+
updateDriverMarker(lat: number, lon: number, heading?: number): void;
|
|
190
|
+
|
|
191
|
+
// Navigation (browser only)
|
|
192
|
+
startNavigation(fromLat: number, fromLon: number, toLat: number, toLon: number, opts?: NavigationOptions): Promise<NavigationSession>;
|
|
193
|
+
stopNavigation(): void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default MaaSClient;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @maas/client — Map-as-a-Service SDK
|
|
3
|
+
*
|
|
4
|
+
* Arabic maps, routing, and navigation for Mauritania.
|
|
5
|
+
* Works in the browser (with MapLibre GL JS) and in React Native / Node for API calls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Polyline6 decoder (Google/OSRM format, precision 1e-6) ────────────────────
|
|
9
|
+
|
|
10
|
+
function _decodePolyline6(encoded) {
|
|
11
|
+
const coords = [];
|
|
12
|
+
let index = 0, lat = 0, lng = 0;
|
|
13
|
+
while (index < encoded.length) {
|
|
14
|
+
let b, shift = 0, result = 0;
|
|
15
|
+
do {
|
|
16
|
+
b = encoded.charCodeAt(index++) - 63;
|
|
17
|
+
result |= (b & 0x1f) << shift;
|
|
18
|
+
shift += 5;
|
|
19
|
+
} while (b >= 0x20);
|
|
20
|
+
lat += result & 1 ? ~(result >> 1) : result >> 1;
|
|
21
|
+
|
|
22
|
+
shift = 0; result = 0;
|
|
23
|
+
do {
|
|
24
|
+
b = encoded.charCodeAt(index++) - 63;
|
|
25
|
+
result |= (b & 0x1f) << shift;
|
|
26
|
+
shift += 5;
|
|
27
|
+
} while (b >= 0x20);
|
|
28
|
+
lng += result & 1 ? ~(result >> 1) : result >> 1;
|
|
29
|
+
|
|
30
|
+
coords.push([lng / 1e6, lat / 1e6]);
|
|
31
|
+
}
|
|
32
|
+
return coords;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── MaaSClient ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export class MaaSClient {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {string} options.baseUrl Base URL of your MaaS API (no trailing slash)
|
|
41
|
+
* @param {string} options.apiKey Your API key (X-API-Key header)
|
|
42
|
+
*/
|
|
43
|
+
constructor({ baseUrl, apiKey }) {
|
|
44
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
45
|
+
this.apiKey = apiKey;
|
|
46
|
+
this._map = null;
|
|
47
|
+
this._driverMarker = null;
|
|
48
|
+
this._routeLayerAdded = false;
|
|
49
|
+
this._navSession = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Internal HTTP helpers ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async _get(path, params = {}) {
|
|
55
|
+
const url = new URL(this.baseUrl + path);
|
|
56
|
+
Object.entries(params).forEach(([k, v]) => {
|
|
57
|
+
if (v !== null && v !== undefined) url.searchParams.set(k, v);
|
|
58
|
+
});
|
|
59
|
+
const resp = await fetch(url.toString(), {
|
|
60
|
+
headers: { 'X-API-Key': this.apiKey },
|
|
61
|
+
});
|
|
62
|
+
if (!resp.ok) {
|
|
63
|
+
const err = await resp.json().catch(() => ({}));
|
|
64
|
+
throw new Error(`MaaS API error ${resp.status}: ${err.detail || err.message || resp.statusText}`);
|
|
65
|
+
}
|
|
66
|
+
return resp.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _post(path, body) {
|
|
70
|
+
const resp = await fetch(this.baseUrl + path, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify(body),
|
|
74
|
+
});
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
const err = await resp.json().catch(() => ({}));
|
|
77
|
+
throw new Error(`MaaS API error ${resp.status}: ${err.detail || err.message || resp.statusText}`);
|
|
78
|
+
}
|
|
79
|
+
return resp.json();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Places ─────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fuzzy-search places by Hassaniya Arabic name or variant spellings.
|
|
86
|
+
* @param {string} query
|
|
87
|
+
* @param {{ lat?: number, lon?: number, radiusKm?: number, limit?: number }} opts
|
|
88
|
+
* @returns {Promise<{ places: PlaceResult[], total: number }>}
|
|
89
|
+
*/
|
|
90
|
+
searchPlaces(query, opts = {}) {
|
|
91
|
+
return this._get('/v1/places/search', {
|
|
92
|
+
q: query,
|
|
93
|
+
lat: opts.lat,
|
|
94
|
+
lon: opts.lon,
|
|
95
|
+
radius_km: opts.radiusKm,
|
|
96
|
+
limit: opts.limit ?? 10,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get a single place by ID.
|
|
102
|
+
* @param {string} placeId
|
|
103
|
+
* @returns {Promise<PlaceResult>}
|
|
104
|
+
*/
|
|
105
|
+
getPlace(placeId) {
|
|
106
|
+
return this._get(`/v1/places/${encodeURIComponent(placeId)}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Geocoding ──────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert a place name to coordinates.
|
|
113
|
+
* @param {string} query
|
|
114
|
+
* @returns {Promise<ForwardGeocodeResult>}
|
|
115
|
+
*/
|
|
116
|
+
forwardGeocode(query) {
|
|
117
|
+
return this._get('/v1/geocode/forward', { q: query });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Convert coordinates to the nearest known place name.
|
|
122
|
+
* @param {number} lat
|
|
123
|
+
* @param {number} lon
|
|
124
|
+
* @param {number} [maxDistM=500]
|
|
125
|
+
* @returns {Promise<ReverseGeocodeResult>}
|
|
126
|
+
*/
|
|
127
|
+
reverseGeocode(lat, lon, maxDistM = 500) {
|
|
128
|
+
return this._get('/v1/geocode/reverse', { lat, lon, max_dist_m: maxDistM });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Routing ────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get turn-by-turn directions between two points.
|
|
135
|
+
* @param {number} fromLat
|
|
136
|
+
* @param {number} fromLon
|
|
137
|
+
* @param {number} toLat
|
|
138
|
+
* @param {number} toLon
|
|
139
|
+
* @param {'ar'|'en'} [language='ar']
|
|
140
|
+
* @returns {Promise<RouteResult>}
|
|
141
|
+
*/
|
|
142
|
+
getRoute(fromLat, fromLon, toLat, toLon, language = 'ar') {
|
|
143
|
+
return this._get('/v1/route', {
|
|
144
|
+
from_lat: fromLat, from_lon: fromLon,
|
|
145
|
+
to_lat: toLat, to_lon: toLon,
|
|
146
|
+
language,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get navigation progress for a live GPS position on an active route.
|
|
152
|
+
* Call this on every GPS fix during navigation.
|
|
153
|
+
* @param {string} routeId - From getRoute() response
|
|
154
|
+
* @param {number} lat
|
|
155
|
+
* @param {number} lon
|
|
156
|
+
* @returns {Promise<RouteProgress>}
|
|
157
|
+
*/
|
|
158
|
+
getRouteProgress(routeId, lat, lon) {
|
|
159
|
+
return this._get('/v1/route/progress', { route_id: routeId, lat, lon });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── ETA ────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get ETA between two points.
|
|
166
|
+
* @param {number} fromLat
|
|
167
|
+
* @param {number} fromLon
|
|
168
|
+
* @param {number} toLat
|
|
169
|
+
* @param {number} toLon
|
|
170
|
+
* @returns {Promise<EtaResult>}
|
|
171
|
+
*/
|
|
172
|
+
getEta(fromLat, fromLon, toLat, toLon) {
|
|
173
|
+
return this._get('/v1/eta', {
|
|
174
|
+
from_lat: fromLat, from_lon: fromLon,
|
|
175
|
+
to_lat: toLat, to_lon: toLon,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Compute a distance/duration matrix (max 10×10).
|
|
181
|
+
* Useful for dispatch: find nearest available driver.
|
|
182
|
+
* @param {Array<{lat: number, lon: number}>} origins
|
|
183
|
+
* @param {Array<{lat: number, lon: number}>} destinations
|
|
184
|
+
* @returns {Promise<EtaMatrixResult>}
|
|
185
|
+
*/
|
|
186
|
+
getEtaMatrix(origins, destinations) {
|
|
187
|
+
return this._post('/v1/eta/matrix', { origins, destinations });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Driver Location ────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Push current driver location (HTTP).
|
|
194
|
+
* @param {string} driverId
|
|
195
|
+
* @param {number} lat
|
|
196
|
+
* @param {number} lon
|
|
197
|
+
* @param {{ heading?: number, speedKmh?: number }} [opts]
|
|
198
|
+
*/
|
|
199
|
+
updateDriverLocation(driverId, lat, lon, opts = {}) {
|
|
200
|
+
return this._post(`/v1/drivers/${encodeURIComponent(driverId)}/location`, {
|
|
201
|
+
lat, lon,
|
|
202
|
+
heading: opts.heading,
|
|
203
|
+
speed_kmh: opts.speedKmh,
|
|
204
|
+
timestamp: new Date().toISOString(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get nearby active drivers.
|
|
210
|
+
* @param {number} lat
|
|
211
|
+
* @param {number} lon
|
|
212
|
+
* @param {number} [radiusKm=5]
|
|
213
|
+
* @returns {Promise<NearbyDriversResult>}
|
|
214
|
+
*/
|
|
215
|
+
getNearbyDrivers(lat, lon, radiusKm = 5) {
|
|
216
|
+
return this._get('/v1/drivers/nearby', { lat, lon, radius_km: radiusKm });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Real-time Tracking (WebSocket) ─────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Open a WebSocket to stream driver location updates. Call from the driver app.
|
|
223
|
+
* @param {string} tripId
|
|
224
|
+
* @param {string} driverId
|
|
225
|
+
* @returns {{ send: (lat: number, lon: number, opts?: object) => void, end: () => void, close: () => void }}
|
|
226
|
+
*/
|
|
227
|
+
trackDriver(tripId, driverId) {
|
|
228
|
+
const wsUrl = this.baseUrl.replace(/^http/, 'ws')
|
|
229
|
+
+ `/v1/tracking/${encodeURIComponent(tripId)}/driver`
|
|
230
|
+
+ `?api_key=${encodeURIComponent(this.apiKey)}&driver_id=${encodeURIComponent(driverId)}`;
|
|
231
|
+
|
|
232
|
+
const ws = new WebSocket(wsUrl);
|
|
233
|
+
ws.onopen = () => console.debug('[MaaS] Driver tracking connected');
|
|
234
|
+
ws.onerror = (e) => console.error('[MaaS] Driver WS error', e);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
send(lat, lon, opts = {}) {
|
|
238
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
239
|
+
ws.send(JSON.stringify({
|
|
240
|
+
type: 'location', lat, lon,
|
|
241
|
+
heading: opts.heading,
|
|
242
|
+
speed_kmh: opts.speedKmh,
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
end() {
|
|
248
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
249
|
+
ws.send(JSON.stringify({ type: 'trip_ended' }));
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
close() { ws.close(); },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Subscribe to driver location updates. Call from the passenger app.
|
|
258
|
+
* @param {string} tripId
|
|
259
|
+
* @param {(location: object) => void} onLocation
|
|
260
|
+
* @param {() => void} [onTripEnded]
|
|
261
|
+
* @returns {{ close: () => void }}
|
|
262
|
+
*/
|
|
263
|
+
trackPassenger(tripId, onLocation, onTripEnded = () => {}) {
|
|
264
|
+
const wsUrl = this.baseUrl.replace(/^http/, 'ws')
|
|
265
|
+
+ `/v1/tracking/${encodeURIComponent(tripId)}/passenger`
|
|
266
|
+
+ `?api_key=${encodeURIComponent(this.apiKey)}`;
|
|
267
|
+
|
|
268
|
+
const ws = new WebSocket(wsUrl);
|
|
269
|
+
ws.onmessage = (event) => {
|
|
270
|
+
try {
|
|
271
|
+
const data = JSON.parse(event.data);
|
|
272
|
+
if (data.type === 'driver_location') onLocation(data);
|
|
273
|
+
if (data.type === 'trip_ended') onTripEnded();
|
|
274
|
+
} catch { /* ignore malformed */ }
|
|
275
|
+
};
|
|
276
|
+
ws.onerror = (e) => console.error('[MaaS] Passenger WS error', e);
|
|
277
|
+
|
|
278
|
+
const ping = setInterval(() => {
|
|
279
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
|
|
280
|
+
}, 25000);
|
|
281
|
+
|
|
282
|
+
return { close() { clearInterval(ping); ws.close(); } };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Map (browser only) ─────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Initialize a MapLibre GL map centred on Nouakchott.
|
|
289
|
+
* Requires `maplibre-gl` to be installed/loaded.
|
|
290
|
+
* @param {string|HTMLElement} container DOM element id or element reference
|
|
291
|
+
* @param {{ center?: [number,number], zoom?: number, pitch?: number, bearing?: number, maxZoom?: number, style?: string }} [opts]
|
|
292
|
+
* @returns {import('maplibre-gl').Map}
|
|
293
|
+
*/
|
|
294
|
+
createMap(container, opts = {}) {
|
|
295
|
+
// Support both CDN global and npm import
|
|
296
|
+
const mgl = typeof maplibregl !== 'undefined' ? maplibregl : opts._maplibregl;
|
|
297
|
+
if (!mgl) throw new Error('maplibre-gl is not available. Pass it as opts._maplibregl or load the CDN script.');
|
|
298
|
+
|
|
299
|
+
this._maplibregl = mgl;
|
|
300
|
+
const map = new mgl.Map({
|
|
301
|
+
container,
|
|
302
|
+
style: opts.style ?? `${this.baseUrl}/tiles/styles/mauritania-ar/style.json`,
|
|
303
|
+
center: opts.center ?? [-15.9785, 18.0864],
|
|
304
|
+
zoom: opts.zoom ?? 13,
|
|
305
|
+
pitch: opts.pitch ?? 45,
|
|
306
|
+
bearing: opts.bearing ?? 0,
|
|
307
|
+
maxZoom: opts.maxZoom ?? 18,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
this._map = map;
|
|
311
|
+
return map;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Draw (or update) the route polyline on the map.
|
|
316
|
+
* @param {RouteResult} route Response from getRoute()
|
|
317
|
+
*/
|
|
318
|
+
showRoute(route) {
|
|
319
|
+
if (!this._map) throw new Error('Call createMap() first');
|
|
320
|
+
const coordinates = _decodePolyline6(route.geometry);
|
|
321
|
+
|
|
322
|
+
if (this._routeLayerAdded) {
|
|
323
|
+
this._map.getSource('maas-route').setData({
|
|
324
|
+
type: 'Feature', geometry: { type: 'LineString', coordinates },
|
|
325
|
+
});
|
|
326
|
+
} else {
|
|
327
|
+
this._map.addSource('maas-route', {
|
|
328
|
+
type: 'geojson',
|
|
329
|
+
data: { type: 'Feature', geometry: { type: 'LineString', coordinates } },
|
|
330
|
+
});
|
|
331
|
+
this._map.addLayer({
|
|
332
|
+
id: 'maas-route-line', type: 'line', source: 'maas-route',
|
|
333
|
+
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
334
|
+
paint: { 'line-color': '#2563eb', 'line-width': 5, 'line-opacity': 0.85 },
|
|
335
|
+
});
|
|
336
|
+
this._routeLayerAdded = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const lngs = coordinates.map(c => c[0]);
|
|
340
|
+
const lats = coordinates.map(c => c[1]);
|
|
341
|
+
this._map.fitBounds(
|
|
342
|
+
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
|
343
|
+
{ padding: 60 }
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Update (or create) the driver marker on the map.
|
|
349
|
+
* @param {number} lat
|
|
350
|
+
* @param {number} lon
|
|
351
|
+
* @param {number} [heading=0]
|
|
352
|
+
*/
|
|
353
|
+
updateDriverMarker(lat, lon, heading = 0) {
|
|
354
|
+
if (!this._map) throw new Error('Call createMap() first');
|
|
355
|
+
if (!this._driverMarker) {
|
|
356
|
+
const el = document.createElement('div');
|
|
357
|
+
el.style.cssText = 'width:36px;height:36px;background:#2563eb;border-radius:50%;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.3);cursor:pointer;';
|
|
358
|
+
this._driverMarker = new this._maplibregl.Marker({ element: el, rotation: heading })
|
|
359
|
+
.setLngLat([lon, lat]).addTo(this._map);
|
|
360
|
+
} else {
|
|
361
|
+
this._driverMarker.setLngLat([lon, lat]).setRotation(heading);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Navigation ─────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Start turn-by-turn navigation between two points.
|
|
369
|
+
*
|
|
370
|
+
* Handles everything internally:
|
|
371
|
+
* - Fetches route and draws it on the map
|
|
372
|
+
* - Watches device GPS and moves camera to follow
|
|
373
|
+
* - Polls /route/progress on every GPS fix
|
|
374
|
+
* - Auto re-routes when off-route
|
|
375
|
+
* - Fires callbacks so your UI can show instructions, ETA, etc.
|
|
376
|
+
*
|
|
377
|
+
* @param {number} fromLat
|
|
378
|
+
* @param {number} fromLon
|
|
379
|
+
* @param {number} toLat
|
|
380
|
+
* @param {number} toLon
|
|
381
|
+
* @param {{
|
|
382
|
+
* language?: 'ar'|'en',
|
|
383
|
+
* arrivalThresholdM?: number,
|
|
384
|
+
* onProgress?: (progress: RouteProgress) => void,
|
|
385
|
+
* onStep?: (step: RouteStep, index: number) => void,
|
|
386
|
+
* onOffRoute?: (distanceM: number) => void,
|
|
387
|
+
* onArrival?: () => void,
|
|
388
|
+
* onError?: (err: Error) => void,
|
|
389
|
+
* }} [opts]
|
|
390
|
+
* @returns {Promise<{ route: RouteResult, stop: () => void }>}
|
|
391
|
+
*/
|
|
392
|
+
async startNavigation(fromLat, fromLon, toLat, toLon, opts = {}) {
|
|
393
|
+
if (!this._map) throw new Error('Call createMap() first');
|
|
394
|
+
|
|
395
|
+
const {
|
|
396
|
+
language = 'ar',
|
|
397
|
+
arrivalThresholdM = 30,
|
|
398
|
+
onProgress = () => {},
|
|
399
|
+
onStep = () => {},
|
|
400
|
+
onOffRoute = () => {},
|
|
401
|
+
onArrival = () => {},
|
|
402
|
+
onError = () => {},
|
|
403
|
+
} = opts;
|
|
404
|
+
|
|
405
|
+
const route = await this.getRoute(fromLat, fromLon, toLat, toLon, language);
|
|
406
|
+
this.showRoute(route);
|
|
407
|
+
|
|
408
|
+
let routeId = route.route_id;
|
|
409
|
+
let currentStepIdx = -1;
|
|
410
|
+
let reroutePending = false;
|
|
411
|
+
let stopped = false;
|
|
412
|
+
let watchId = null;
|
|
413
|
+
|
|
414
|
+
const el = document.createElement('div');
|
|
415
|
+
el.style.cssText = 'width:22px;height:22px;border-radius:50%;background:#2563eb;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.4);';
|
|
416
|
+
const navMarker = new this._maplibregl.Marker({ element: el, rotationAlignment: 'map' })
|
|
417
|
+
.setLngLat([fromLon, fromLat]).addTo(this._map);
|
|
418
|
+
|
|
419
|
+
this._map.easeTo({ center: [fromLon, fromLat], zoom: 17, pitch: 60, bearing: 0, duration: 800 });
|
|
420
|
+
|
|
421
|
+
const handlePosition = async ({ coords }) => {
|
|
422
|
+
if (stopped) return;
|
|
423
|
+
const { latitude: lat, longitude: lon, heading } = coords;
|
|
424
|
+
|
|
425
|
+
navMarker.setLngLat([lon, lat]);
|
|
426
|
+
if (heading !== null) navMarker.setRotation(heading);
|
|
427
|
+
this._map.easeTo({ center: [lon, lat], bearing: heading ?? 0, duration: 400 });
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const progress = await this.getRouteProgress(routeId, lat, lon);
|
|
431
|
+
onProgress(progress);
|
|
432
|
+
|
|
433
|
+
if (progress.step_index !== currentStepIdx) {
|
|
434
|
+
currentStepIdx = progress.step_index;
|
|
435
|
+
onStep(progress.current_step, progress.step_index);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (progress.off_route && !reroutePending) {
|
|
439
|
+
reroutePending = true;
|
|
440
|
+
onOffRoute(progress.off_route_distance_m);
|
|
441
|
+
try {
|
|
442
|
+
const newRoute = await this.getRoute(lat, lon, toLat, toLon, language);
|
|
443
|
+
routeId = newRoute.route_id;
|
|
444
|
+
this.showRoute(newRoute);
|
|
445
|
+
currentStepIdx = -1;
|
|
446
|
+
} catch (e) { onError(e); }
|
|
447
|
+
finally { reroutePending = false; }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (progress.distance_remaining_m <= arrivalThresholdM) {
|
|
451
|
+
onArrival();
|
|
452
|
+
stop();
|
|
453
|
+
}
|
|
454
|
+
} catch (e) { onError(e); }
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
if (!navigator.geolocation) throw new Error('Geolocation not supported by this browser.');
|
|
458
|
+
watchId = navigator.geolocation.watchPosition(
|
|
459
|
+
handlePosition,
|
|
460
|
+
(e) => onError(new Error(`GPS: ${e.message}`)),
|
|
461
|
+
{ enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 },
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
function stop() {
|
|
465
|
+
if (stopped) return;
|
|
466
|
+
stopped = true;
|
|
467
|
+
if (watchId !== null) navigator.geolocation.clearWatch(watchId);
|
|
468
|
+
navMarker.remove();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this._navSession = { stop };
|
|
472
|
+
return { route, stop };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Stop an active navigation session. */
|
|
476
|
+
stopNavigation() {
|
|
477
|
+
if (this._navSession) { this._navSession.stop(); this._navSession = null; }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export default MaaSClient;
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maas-mr/client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "JavaScript SDK for Map-as-a-Service — Arabic maps, routing, and navigation for Mauritania",
|
|
5
|
+
"keywords": ["mauritania", "nouakchott", "maps", "arabic", "routing", "navigation", "maplibre"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "MaaS",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"homepage": "https://api.healthycornermauritanie.me",
|
|
10
|
+
"main": "./dist/index.cjs",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs",
|
|
17
|
+
"types": "./dist/index.d.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "node build.js"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"maplibre-gl": ">=3.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"maplibre-gl": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|