@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 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;
@@ -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
+ }